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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-04 09:10:10 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-04 09:10:10 +0300
commit2fa68d3a97fd31bf469050e130f0fc95e8944316 (patch)
tree5c00585c55c44917765c152426cb58c803b4f57f /spec
parent21be9646a94e2c145897e25d9c521523d55e1614 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb85
-rw-r--r--spec/factories/design_management/actions.rb13
-rw-r--r--spec/factories/design_management/design_at_version.rb23
-rw-r--r--spec/factories/design_management/designs.rb128
-rw-r--r--spec/factories/design_management/versions.rb142
-rw-r--r--spec/factories/notes.rb18
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/factories/uploads.rb6
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_spec.js12
-rw-r--r--spec/lib/gitlab/git_access_design_spec.rb54
-rw-r--r--spec/lib/gitlab/gl_repository/repo_type_spec.rb37
-rw-r--r--spec/lib/gitlab/gl_repository_spec.rb15
-rw-r--r--spec/models/design_management/action_spec.rb105
-rw-r--r--spec/models/design_management/design_action_spec.rb98
-rw-r--r--spec/models/design_management/design_at_version_spec.rb426
-rw-r--r--spec/models/design_management/design_collection_spec.rb82
-rw-r--r--spec/models/design_management/design_spec.rb582
-rw-r--r--spec/models/design_management/repository_spec.rb58
-rw-r--r--spec/models/design_management/version_spec.rb342
-rw-r--r--spec/models/design_user_mention_spec.rb12
-rw-r--r--spec/models/diff_note_spec.rb18
-rw-r--r--spec/models/issue_spec.rb56
-rw-r--r--spec/models/note_spec.rb8
-rw-r--r--spec/models/project_spec.rb23
-rw-r--r--spec/services/design_management/design_user_notes_count_service_spec.rb52
-rw-r--r--spec/services/issues/related_branches_service_spec.rb102
-rw-r--r--spec/support/helpers/design_management_test_helpers.rb45
-rw-r--r--spec/uploaders/design_management/design_v432x230_uploader_spec.rb86
-rw-r--r--spec/views/projects/issues/_related_branches.html.haml_spec.rb24
29 files changed, 2627 insertions, 31 deletions
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 35b43afbcf3..a2df023fe4c 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -243,6 +243,91 @@ describe Projects::IssuesController do
end
end
+ describe '#related_branches' do
+ subject { get :related_branches, params: params, format: :json }
+
+ before do
+ sign_in(user)
+ project.add_developer(developer)
+ end
+
+ let(:developer) { user }
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: issue.iid
+ }
+ end
+
+ context 'the current user cannot download code' do
+ it 'prevents access' do
+ allow(controller).to receive(:can?).with(any_args).and_return(true)
+ allow(controller).to receive(:can?).with(user, :download_code, project).and_return(false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'there are no related branches' do
+ it 'assigns empty arrays', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:related_branches)).to be_empty
+ expect(response).to render_template('projects/issues/_related_branches')
+ expect(json_response).to eq('html' => '')
+ end
+ end
+
+ context 'there are related branches' do
+ let(:missing_branch) { "#{issue.to_branch_name}-missing" }
+ let(:unreadable_branch) { "#{issue.to_branch_name}-unreadable" }
+ let(:pipeline) { build(:ci_pipeline, :success, project: project) }
+ let(:master_branch) { 'master' }
+
+ let(:related_branches) do
+ [
+ branch_info(issue.to_branch_name, pipeline.detailed_status(user)),
+ branch_info(missing_branch, nil),
+ branch_info(unreadable_branch, nil)
+ ]
+ end
+
+ def branch_info(name, status)
+ {
+ name: name,
+ link: controller.project_compare_path(project, from: master_branch, to: name),
+ pipeline_status: status
+ }
+ end
+
+ before do
+ allow(controller).to receive(:find_routable!)
+ .with(Project, project.full_path, any_args).and_return(project)
+ allow(project).to receive(:default_branch).and_return(master_branch)
+ allow_next_instance_of(Issues::RelatedBranchesService) do |service|
+ allow(service).to receive(:execute).and_return(related_branches)
+ end
+ end
+
+ it 'finds and assigns the appropriate branch information', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:related_branches)).to contain_exactly(
+ branch_info(issue.to_branch_name, an_instance_of(Gitlab::Ci::Status::Success)),
+ branch_info(missing_branch, be_nil),
+ branch_info(unreadable_branch, be_nil)
+ )
+ expect(response).to render_template('projects/issues/_related_branches')
+ expect(json_response).to match('html' => String)
+ end
+ end
+ end
+
# This spec runs as a request-style spec in order to invoke the
# Rails router. A controller-style spec matches the wrong route, and
# session['user_return_to'] becomes incorrect.
diff --git a/spec/factories/design_management/actions.rb b/spec/factories/design_management/actions.rb
new file mode 100644
index 00000000000..e2561f98f52
--- /dev/null
+++ b/spec/factories/design_management/actions.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_action, class: 'DesignManagement::Action' do
+ design
+ association :version, factory: :design_version
+ event { :creation }
+
+ trait :with_image_v432x230 do
+ image_v432x230 { fixture_file_upload('spec/fixtures/dk.png') }
+ end
+ end
+end
diff --git a/spec/factories/design_management/design_at_version.rb b/spec/factories/design_management/design_at_version.rb
new file mode 100644
index 00000000000..b73df71595c
--- /dev/null
+++ b/spec/factories/design_management/design_at_version.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_at_version, class: 'DesignManagement::DesignAtVersion' do
+ skip_create # This is not an Active::Record model.
+
+ design { nil }
+
+ version { nil }
+
+ transient do
+ issue { design&.issue || version&.issue || create(:issue) }
+ end
+
+ initialize_with do
+ attrs = attributes.dup
+ attrs[:design] ||= create(:design, issue: issue)
+ attrs[:version] ||= create(:design_version, issue: issue)
+
+ new(attrs)
+ end
+ end
+end
diff --git a/spec/factories/design_management/designs.rb b/spec/factories/design_management/designs.rb
new file mode 100644
index 00000000000..59d4cc56f95
--- /dev/null
+++ b/spec/factories/design_management/designs.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design, class: 'DesignManagement::Design' do
+ issue { create(:issue) }
+ project { issue&.project || create(:project) }
+ sequence(:filename) { |n| "homescreen-#{n}.jpg" }
+
+ transient do
+ author { issue.author }
+ end
+
+ trait :importing do
+ issue { nil }
+
+ importing { true }
+ imported { false }
+ end
+
+ trait :imported do
+ importing { false }
+ imported { true }
+ end
+
+ create_versions = ->(design, evaluator, commit_version) do
+ unless evaluator.versions_count.zero?
+ project = design.project
+ issue = design.issue
+ repository = project.design_repository
+ repository.create_if_not_exists
+ dv_table_name = DesignManagement::Action.table_name
+ updates = [0, evaluator.versions_count - (evaluator.deleted ? 2 : 1)].max
+
+ run_action = ->(action) do
+ sha = commit_version[action]
+ version = DesignManagement::Version.new(sha: sha, issue: issue, author: evaluator.author)
+ version.save(validate: false) # We need it to have an ID, validate later
+ Gitlab::Database.bulk_insert(dv_table_name, [action.row_attrs(version)])
+ end
+
+ # always a creation
+ run_action[DesignManagement::DesignAction.new(design, :create, evaluator.file)]
+
+ # 0 or more updates
+ updates.times do
+ run_action[DesignManagement::DesignAction.new(design, :update, evaluator.file)]
+ end
+
+ # and maybe a deletion
+ run_action[DesignManagement::DesignAction.new(design, :delete)] if evaluator.deleted
+ end
+
+ design.clear_version_cache
+ end
+
+ # Use this trait to build designs that are backed by Git LFS, committed
+ # to the repository, and with an LfsObject correctly created for it.
+ trait :with_lfs_file do
+ with_file
+
+ transient do
+ raw_file { fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
+ lfs_pointer { Gitlab::Git::LfsPointerFile.new(SecureRandom.random_bytes) }
+ file { lfs_pointer.pointer }
+ end
+
+ after :create do |design, evaluator|
+ lfs_object = create(:lfs_object, file: evaluator.raw_file, oid: evaluator.lfs_pointer.sha256, size: evaluator.lfs_pointer.size)
+ create(:lfs_objects_project, project: design.project, lfs_object: lfs_object, repository_type: :design)
+ end
+ end
+
+ # Use this trait if you want versions in a particular history, but don't
+ # want to pay for gitlay calls.
+ trait :with_versions do
+ transient do
+ deleted { false }
+ versions_count { 1 }
+ sequence(:file) { |n| "some-file-content-#{n}" }
+ end
+
+ after :create do |design, evaluator|
+ counter = (1..).lazy
+
+ # Just produce a SHA by hashing the action and a monotonic counter
+ commit_version = ->(action) do
+ Digest::SHA1.hexdigest("#{action.gitaly_action}.#{counter.next}")
+ end
+
+ create_versions[design, evaluator, commit_version]
+ end
+ end
+
+ # Use this trait to build designs that have commits in the repository
+ # and files that can be retrieved.
+ trait :with_file do
+ transient do
+ deleted { false }
+ versions_count { 1 }
+ file { File.join(Rails.root, 'spec/fixtures/dk.png') }
+ end
+
+ after :create do |design, evaluator|
+ project = design.project
+ repository = project.design_repository
+
+ commit_version = ->(action) do
+ repository.multi_action(
+ evaluator.author,
+ branch_name: 'master',
+ message: "#{action.action} for #{design.filename}",
+ actions: [action.gitaly_action]
+ )
+ end
+
+ create_versions[design, evaluator, commit_version]
+ end
+ end
+
+ trait :with_smaller_image_versions do
+ with_lfs_file
+
+ after :create do |design|
+ design.versions.each { |v| DesignManagement::GenerateImageVersionsService.new(v).execute }
+ end
+ end
+ end
+end
diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb
new file mode 100644
index 00000000000..878665e02e5
--- /dev/null
+++ b/spec/factories/design_management/versions.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :design_version, class: 'DesignManagement::Version' do
+ sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
+ issue { designs.first&.issue || create(:issue) }
+ author { issue&.author || create(:user) }
+
+ transient do
+ designs_count { 1 }
+ created_designs { [] }
+ modified_designs { [] }
+ deleted_designs { [] }
+ end
+
+ # Warning: this will intentionally result in an invalid version!
+ trait :empty do
+ designs_count { 0 }
+ end
+
+ trait :importing do
+ issue { nil }
+
+ designs_count { 0 }
+ importing { true }
+ imported { false }
+ end
+
+ trait :imported do
+ importing { false }
+ imported { true }
+ end
+
+ after(:build) do |version, evaluator|
+ # By default all designs are created_designs, so just add them.
+ specific_designs = [].concat(
+ evaluator.created_designs,
+ evaluator.modified_designs,
+ evaluator.deleted_designs
+ )
+ version.designs += specific_designs
+
+ unless evaluator.designs_count.zero? || version.designs.present?
+ version.designs << create(:design, issue: version.issue)
+ end
+ end
+
+ after :create do |version, evaluator|
+ # FactoryBot does not like methods, so we use lambdas instead
+ events = DesignManagement::Action.events
+
+ version.actions
+ .where(design_id: evaluator.modified_designs.map(&:id))
+ .update_all(event: events[:modification])
+
+ version.actions
+ .where(design_id: evaluator.deleted_designs.map(&:id))
+ .update_all(event: events[:deletion])
+
+ version.designs.reload
+ # Ensure version.issue == design.issue for all version.designs
+ version.designs.update_all(issue_id: version.issue_id)
+
+ needed = evaluator.designs_count
+ have = version.designs.size
+
+ create_list(:design, [0, needed - have].max, issue: version.issue).each do |d|
+ version.designs << d
+ end
+
+ version.actions.reset
+ end
+
+ # Use this trait to build versions with designs that are backed by Git LFS, committed
+ # to the repository, and with an LfsObject correctly created for it.
+ trait :with_lfs_file do
+ committed
+
+ transient do
+ raw_file { fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
+ lfs_pointer { Gitlab::Git::LfsPointerFile.new(SecureRandom.random_bytes) }
+ file { lfs_pointer.pointer }
+ end
+
+ after :create do |version, evaluator|
+ lfs_object = create(:lfs_object, file: evaluator.raw_file, oid: evaluator.lfs_pointer.sha256, size: evaluator.lfs_pointer.size)
+ create(:lfs_objects_project, project: version.project, lfs_object: lfs_object, repository_type: :design)
+ end
+ end
+
+ # This trait is for versions that must be present in the git repository.
+ trait :committed do
+ transient do
+ file { File.join(Rails.root, 'spec/fixtures/dk.png') }
+ end
+
+ after :create do |version, evaluator|
+ project = version.issue.project
+ repository = project.design_repository
+ repository.create_if_not_exists
+
+ designs = version.designs_by_event
+ base_change = { content: evaluator.file }
+
+ actions = %w[modification deletion].flat_map { |k| designs.fetch(k, []) }.map do |design|
+ base_change.merge(action: :create, file_path: design.full_path)
+ end
+
+ if actions.present?
+ repository.multi_action(
+ evaluator.author,
+ branch_name: 'master',
+ message: "created #{actions.size} files",
+ actions: actions
+ )
+ end
+
+ mapping = {
+ 'creation' => :create,
+ 'modification' => :update,
+ 'deletion' => :delete
+ }
+
+ version_actions = designs.flat_map do |(event, designs)|
+ base = event == 'deletion' ? {} : base_change
+ designs.map do |design|
+ base.merge(action: mapping[event], file_path: design.full_path)
+ end
+ end
+
+ sha = repository.multi_action(
+ evaluator.author,
+ branch_name: 'master',
+ message: "edited #{version_actions.size} files",
+ actions: version_actions
+ )
+
+ version.update(sha: sha)
+ end
+ end
+ end
+end
diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb
index fdd1a9a18b2..0868e38f70e 100644
--- a/spec/factories/notes.rb
+++ b/spec/factories/notes.rb
@@ -107,6 +107,10 @@ FactoryBot.define do
end
end
+ factory :diff_note_on_design, parent: :note, traits: [:on_design], class: 'DiffNote' do
+ position { build(:image_diff_position, file: noteable.full_path, diff_refs: noteable.diff_refs) }
+ end
+
trait :on_commit do
association :project, :repository
noteable { nil }
@@ -136,6 +140,20 @@ FactoryBot.define do
project { nil }
end
+ trait :on_design do
+ transient do
+ issue { association(:issue, project: project) }
+ end
+ noteable { association(:design, :with_file, issue: issue) }
+
+ after(:build) do |note|
+ next if note.project == note.noteable.project
+
+ # note validations require consistency between these two objects
+ note.project = note.noteable.project
+ end
+ end
+
trait :system do
system { true }
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 64321c9f319..45caa7a2b6a 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -215,6 +215,12 @@ FactoryBot.define do
end
end
+ trait :design_repo do
+ after(:create) do |project|
+ raise 'Failed to create design repository!' unless project.design_repository.create_if_not_exists
+ end
+ end
+
trait :remote_mirror do
transient do
remote_name { "remote_mirror_#{SecureRandom.hex}" }
diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb
index a060cd7d6f8..b19af277cc3 100644
--- a/spec/factories/uploads.rb
+++ b/spec/factories/uploads.rb
@@ -65,5 +65,11 @@ FactoryBot.define do
model { create(:note) }
uploader { "AttachmentUploader" }
end
+
+ trait :design_action_image_v432x230_upload do
+ mount_point { :image_v432x230 }
+ model { create(:design_action) }
+ uploader { ::DesignManagement::DesignV432x230Uploader.name }
+ end
end
end
diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js
index a47363f4dc7..e45a9042f71 100644
--- a/spec/frontend/alert_management/components/alert_management_list_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_list_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlEmptyState, GlTable, GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState, GlTable, GlAlert, GlLoadingIcon, GlNewDropdown } from '@gitlab/ui';
import AlertManagementList from '~/alert_management/components/alert_management_list.vue';
import mockAlerts from '../mocks/alerts.json';
@@ -11,6 +11,7 @@ describe('AlertManagementList', () => {
const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findStatusDropdown = () => wrapper.find(GlNewDropdown);
function mountComponent({
props = {
@@ -103,5 +104,14 @@ describe('AlertManagementList', () => {
expect(findAlertsTable().exists()).toBe(true);
expect(findAlerts()).toHaveLength(mockAlerts.length);
});
+
+ it('displays status dropdown', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts, errored: false },
+ loading: false,
+ });
+ expect(findStatusDropdown().exists()).toBe(true);
+ });
});
});
diff --git a/spec/lib/gitlab/git_access_design_spec.rb b/spec/lib/gitlab/git_access_design_spec.rb
new file mode 100644
index 00000000000..b09afc67c90
--- /dev/null
+++ b/spec/lib/gitlab/git_access_design_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe Gitlab::GitAccessDesign do
+ include DesignManagementTestHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { project.owner }
+ let(:protocol) { 'web' }
+ let(:actor) { user }
+
+ subject(:access) do
+ described_class.new(actor, project, protocol, authentication_abilities: [:read_project, :download_code, :push_code])
+ end
+
+ describe '#check' do
+ subject { access.check('git-receive-pack', ::Gitlab::GitAccess::ANY) }
+
+ before do
+ enable_design_management
+ end
+
+ context 'when the user is allowed to manage designs' do
+ # TODO This test is being temporarily skipped unless run in EE,
+ # as we are in the process of moving Design Management to FOSS in 13.0
+ # in steps. In the current step the policies have not yet been moved
+ # which means that although the `GitAccessDesign` class has moved, the
+ # user will always be denied access in FOSS.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283.
+ it do
+ skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
+
+ is_expected.to be_a(::Gitlab::GitAccessResult::Success)
+ end
+ end
+
+ context 'when the user is not allowed to manage designs' do
+ let_it_be(:user) { create(:user) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(::Gitlab::GitAccess::ForbiddenError)
+ end
+ end
+
+ context 'when the protocol is not web' do
+ let(:protocol) { 'https' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(::Gitlab::GitAccess::ForbiddenError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/gl_repository/repo_type_spec.rb b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
index 6185b068d4c..bf6df55b71e 100644
--- a/spec/lib/gitlab/gl_repository/repo_type_spec.rb
+++ b/spec/lib/gitlab/gl_repository/repo_type_spec.rb
@@ -7,6 +7,7 @@ describe Gitlab::GlRepository::RepoType do
let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) }
let(:project_path) { project.repository.full_path }
let(:wiki_path) { project.wiki.repository.full_path }
+ let(:design_path) { project.design_repository.full_path }
let(:personal_snippet_path) { "snippets/#{personal_snippet.id}" }
let(:project_snippet_path) { "#{project.full_path}/snippets/#{project_snippet.id}" }
@@ -24,6 +25,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class).not_to be_wiki
expect(described_class).to be_project
expect(described_class).not_to be_snippet
+ expect(described_class).not_to be_design
end
end
@@ -33,6 +35,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_truthy
expect(described_class.valid?(personal_snippet_path)).to be_truthy
expect(described_class.valid?(project_snippet_path)).to be_truthy
+ expect(described_class.valid?(design_path)).to be_truthy
end
end
end
@@ -51,6 +54,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class).to be_wiki
expect(described_class).not_to be_project
expect(described_class).not_to be_snippet
+ expect(described_class).not_to be_design
end
end
@@ -60,6 +64,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_truthy
expect(described_class.valid?(personal_snippet_path)).to be_falsey
expect(described_class.valid?(project_snippet_path)).to be_falsey
+ expect(described_class.valid?(design_path)).to be_falsey
end
end
end
@@ -79,6 +84,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class).to be_snippet
expect(described_class).not_to be_wiki
expect(described_class).not_to be_project
+ expect(described_class).not_to be_design
end
end
@@ -88,6 +94,7 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_falsey
expect(described_class.valid?(personal_snippet_path)).to be_truthy
expect(described_class.valid?(project_snippet_path)).to be_truthy
+ expect(described_class.valid?(design_path)).to be_falsey
end
end
end
@@ -115,8 +122,38 @@ describe Gitlab::GlRepository::RepoType do
expect(described_class.valid?(wiki_path)).to be_falsey
expect(described_class.valid?(personal_snippet_path)).to be_truthy
expect(described_class.valid?(project_snippet_path)).to be_truthy
+ expect(described_class.valid?(design_path)).to be_falsey
end
end
end
end
+
+ describe Gitlab::GlRepository::DESIGN do
+ it_behaves_like 'a repo type' do
+ let(:expected_identifier) { "design-#{project.id}" }
+ let(:expected_id) { project.id.to_s }
+ let(:expected_suffix) { '.design' }
+ let(:expected_repository) { project.design_repository }
+ let(:expected_container) { project }
+ end
+
+ it 'knows its type' do
+ aggregate_failures do
+ expect(described_class).to be_design
+ expect(described_class).not_to be_project
+ expect(described_class).not_to be_wiki
+ expect(described_class).not_to be_snippet
+ end
+ end
+
+ it 'checks if repository path is valid' do
+ aggregate_failures do
+ expect(described_class.valid?(design_path)).to be_truthy
+ expect(described_class.valid?(project_path)).to be_falsey
+ expect(described_class.valid?(wiki_path)).to be_falsey
+ expect(described_class.valid?(personal_snippet_path)).to be_falsey
+ expect(described_class.valid?(project_snippet_path)).to be_falsey
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb
index 858f436047e..5f5244b7116 100644
--- a/spec/lib/gitlab/gl_repository_spec.rb
+++ b/spec/lib/gitlab/gl_repository_spec.rb
@@ -19,6 +19,10 @@ describe ::Gitlab::GlRepository do
expect(described_class.parse("snippet-#{snippet.id}")).to eq([snippet, nil, Gitlab::GlRepository::SNIPPET])
end
+ it 'parses a design gl_repository' do
+ expect(described_class.parse("design-#{project.id}")).to eq([project, project, Gitlab::GlRepository::DESIGN])
+ end
+
it 'throws an argument error on an invalid gl_repository type' do
expect { described_class.parse("badformat-#{project.id}") }.to raise_error(ArgumentError)
end
@@ -27,4 +31,15 @@ describe ::Gitlab::GlRepository do
expect { described_class.parse("project-foo") }.to raise_error(ArgumentError)
end
end
+
+ describe 'DESIGN' do
+ it 'uses the design access checker' do
+ expect(described_class::DESIGN.access_checker_class).to eq(::Gitlab::GitAccessDesign)
+ end
+
+ it 'builds a design repository' do
+ expect(described_class::DESIGN.repository_resolver.call(create(:project)))
+ .to be_a(::DesignManagement::Repository)
+ end
+ end
end
diff --git a/spec/models/design_management/action_spec.rb b/spec/models/design_management/action_spec.rb
new file mode 100644
index 00000000000..753c31b1549
--- /dev/null
+++ b/spec/models/design_management/action_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::Action do
+ describe 'relations' do
+ it { is_expected.to belong_to(:design) }
+ it { is_expected.to belong_to(:version) }
+ end
+
+ describe 'scopes' do
+ describe '.most_recent' do
+ let_it_be(:design_a) { create(:design) }
+ let_it_be(:design_b) { create(:design) }
+ let_it_be(:design_c) { create(:design) }
+
+ let(:designs) { [design_a, design_b, design_c] }
+
+ before_all do
+ create(:design_version, designs: [design_a, design_b, design_c])
+ create(:design_version, designs: [design_a, design_b])
+ create(:design_version, designs: [design_a])
+ end
+
+ it 'finds the correct version for each design' do
+ dvs = described_class.where(design: designs)
+
+ expected = designs
+ .map(&:id)
+ .zip(dvs.order("version_id DESC").pluck(:version_id).uniq)
+
+ actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] }
+
+ expect(actual).to eq(expected)
+ end
+ end
+
+ describe '.up_to_version' do
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:design_b) { create(:design, issue: issue) }
+
+ # let bindings are not available in before(:all) contexts,
+ # so we need to redefine the array on each construction.
+ let_it_be(:oldest) { create(:design_version, designs: [design_a, design_b]) }
+ let_it_be(:middle) { create(:design_version, designs: [design_a, design_b]) }
+ let_it_be(:newest) { create(:design_version, designs: [design_a, design_b]) }
+
+ subject { described_class.where(design: issue.designs).up_to_version(version) }
+
+ context 'the version is nil' do
+ let(:version) { nil }
+
+ it 'returns all design_versions' do
+ is_expected.to have_attributes(size: 6)
+ end
+ end
+
+ context 'when given a Version instance' do
+ context 'the version is the most current' do
+ let(:version) { newest }
+
+ it { is_expected.to have_attributes(size: 6) }
+ end
+
+ context 'the version is the oldest' do
+ let(:version) { oldest }
+
+ it { is_expected.to have_attributes(size: 2) }
+ end
+
+ context 'the version is the middle one' do
+ let(:version) { middle }
+
+ it { is_expected.to have_attributes(size: 4) }
+ end
+ end
+
+ context 'when given a commit SHA' do
+ context 'the version is the most current' do
+ let(:version) { newest.sha }
+
+ it { is_expected.to have_attributes(size: 6) }
+ end
+
+ context 'the version is the oldest' do
+ let(:version) { oldest.sha }
+
+ it { is_expected.to have_attributes(size: 2) }
+ end
+
+ context 'the version is the middle one' do
+ let(:version) { middle.sha }
+
+ it { is_expected.to have_attributes(size: 4) }
+ end
+ end
+
+ context 'when given a String that is not a commit SHA' do
+ let(:version) { 'foo' }
+
+ it { expect { subject }.to raise_error(ArgumentError) }
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/design_action_spec.rb b/spec/models/design_management/design_action_spec.rb
new file mode 100644
index 00000000000..da4ad41dfcb
--- /dev/null
+++ b/spec/models/design_management/design_action_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DesignAction do
+ describe 'validations' do
+ describe 'the design' do
+ let(:fail_validation) { raise_error(/design/i) }
+
+ it 'must not be nil' do
+ expect { described_class.new(nil, :create, :foo) }.to fail_validation
+ end
+ end
+
+ describe 'the action' do
+ let(:fail_validation) { raise_error(/action/i) }
+
+ it 'must not be nil' do
+ expect { described_class.new(double, nil, :foo) }.to fail_validation
+ end
+
+ it 'must be a known action' do
+ expect { described_class.new(double, :wibble, :foo) }.to fail_validation
+ end
+ end
+
+ describe 'the content' do
+ context 'content is necesary' do
+ let(:fail_validation) { raise_error(/needs content/i) }
+
+ %i[create update].each do |action|
+ it "must not be nil if the action is #{action}" do
+ expect { described_class.new(double, action, nil) }.to fail_validation
+ end
+ end
+ end
+
+ context 'content is forbidden' do
+ let(:fail_validation) { raise_error(/forbids content/i) }
+
+ it "must not be nil if the action is delete" do
+ expect { described_class.new(double, :delete, :foo) }.to fail_validation
+ end
+ end
+ end
+ end
+
+ describe '#gitaly_action' do
+ let(:path) { 'some/path/somewhere' }
+ let(:design) { OpenStruct.new(full_path: path) }
+
+ subject { described_class.new(design, action, content) }
+
+ context 'the action needs content' do
+ let(:action) { :create }
+ let(:content) { :foo }
+
+ it 'produces a good gitaly action' do
+ expect(subject.gitaly_action).to eq(
+ action: action,
+ file_path: path,
+ content: content
+ )
+ end
+ end
+
+ context 'the action forbids content' do
+ let(:action) { :delete }
+ let(:content) { nil }
+
+ it 'produces a good gitaly action' do
+ expect(subject.gitaly_action).to eq(action: action, file_path: path)
+ end
+ end
+ end
+
+ describe '#issue_id' do
+ let(:issue_id) { :foo }
+ let(:design) { OpenStruct.new(issue_id: issue_id) }
+
+ subject { described_class.new(design, :delete) }
+
+ it 'delegates to the design' do
+ expect(subject.issue_id).to eq(issue_id)
+ end
+ end
+
+ describe '#performed' do
+ let(:design) { double }
+
+ subject { described_class.new(design, :delete) }
+
+ it 'calls design#clear_version_cache when the action has been performed' do
+ expect(design).to receive(:clear_version_cache)
+
+ subject.performed
+ end
+ end
+end
diff --git a/spec/models/design_management/design_at_version_spec.rb b/spec/models/design_management/design_at_version_spec.rb
new file mode 100644
index 00000000000..f6fa8df243c
--- /dev/null
+++ b/spec/models/design_management/design_at_version_spec.rb
@@ -0,0 +1,426 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignAtVersion do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue, reload: true) { create(:issue) }
+ let_it_be(:issue_b, reload: true) { create(:issue) }
+ let_it_be(:design, reload: true) { create(:design, issue: issue) }
+ let_it_be(:version) { create(:design_version, designs: [design]) }
+
+ describe '#id' do
+ subject { described_class.new(design: design, version: version) }
+
+ it 'combines design.id and version.id' do
+ expect(subject.id).to include(design.id.to_s, version.id.to_s)
+ end
+ end
+
+ describe '#==' do
+ it 'identifies objects created with the same parameters as equal' do
+ design = build_stubbed(:design, issue: issue)
+ version = build_stubbed(:design_version, designs: [design], issue: issue)
+
+ this = build_stubbed(:design_at_version, design: design, version: version)
+ other = build_stubbed(:design_at_version, design: design, version: version)
+
+ expect(this).to eq(other)
+ expect(other).to eq(this)
+ end
+
+ it 'identifies unequal objects as unequal, by virtue of their version' do
+ design = build_stubbed(:design, issue: issue)
+ version_a = build_stubbed(:design_version, designs: [design])
+ version_b = build_stubbed(:design_version, designs: [design])
+
+ this = build_stubbed(:design_at_version, design: design, version: version_a)
+ other = build_stubbed(:design_at_version, design: design, version: version_b)
+
+ expect(this).not_to eq(nil)
+ expect(this).not_to eq(design)
+ expect(this).not_to eq(other)
+ expect(other).not_to eq(this)
+ end
+
+ it 'identifies unequal objects as unequal, by virtue of their design' do
+ design_a = build_stubbed(:design, issue: issue)
+ design_b = build_stubbed(:design, issue: issue)
+ version = build_stubbed(:design_version, designs: [design_a, design_b])
+
+ this = build_stubbed(:design_at_version, design: design_a, version: version)
+ other = build_stubbed(:design_at_version, design: design_b, version: version)
+
+ expect(this).not_to eq(other)
+ expect(other).not_to eq(this)
+ end
+
+ it 'rejects objects with the same id and the wrong class' do
+ dav = build_stubbed(:design_at_version)
+
+ expect(dav).not_to eq(OpenStruct.new(id: dav.id))
+ end
+
+ it 'expects objects to be of the same type, not subtypes' do
+ subtype = Class.new(described_class)
+ dav = build_stubbed(:design_at_version)
+ other = subtype.new(design: dav.design, version: dav.version)
+
+ expect(dav).not_to eq(other)
+ end
+ end
+
+ describe 'status methods' do
+ let!(:design_a) { create(:design, issue: issue) }
+ let!(:design_b) { create(:design, issue: issue) }
+
+ let!(:version_a) do
+ create(:design_version, designs: [design_a])
+ end
+ let!(:version_b) do
+ create(:design_version, designs: [design_b])
+ end
+ let!(:version_mod) do
+ create(:design_version, modified_designs: [design_a, design_b])
+ end
+ let!(:version_c) do
+ create(:design_version, deleted_designs: [design_a])
+ end
+ let!(:version_d) do
+ create(:design_version, deleted_designs: [design_b])
+ end
+ let!(:version_e) do
+ create(:design_version, designs: [design_a])
+ end
+
+ describe 'a design before it has been created' do
+ subject { build(:design_at_version, design: design_b, version: version_a) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :not_created_yet' do
+ expect(subject).to have_attributes(status: :not_created_yet)
+ end
+ end
+
+ describe 'a design as of its creation' do
+ subject { build(:design_at_version, design: design_a, version: version_a) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+
+ describe 'a design after it has been created, but before deletion' do
+ subject { build(:design_at_version, design: design_b, version: version_c) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+
+ describe 'a design as of its modification' do
+ subject { build(:design_at_version, design: design_a, version: version_mod) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+
+ describe 'a design as of its deletion' do
+ subject { build(:design_at_version, design: design_a, version: version_c) }
+
+ it 'is deleted' do
+ expect(subject).to be_deleted
+ end
+
+ it 'has the status :deleted' do
+ expect(subject).to have_attributes(status: :deleted)
+ end
+ end
+
+ describe 'a design after its deletion' do
+ subject { build(:design_at_version, design: design_b, version: version_e) }
+
+ it 'is deleted' do
+ expect(subject).to be_deleted
+ end
+
+ it 'has the status :deleted' do
+ expect(subject).to have_attributes(status: :deleted)
+ end
+ end
+
+ describe 'a design on its recreation' do
+ subject { build(:design_at_version, design: design_a, version: version_e) }
+
+ it 'is not deleted' do
+ expect(subject).not_to be_deleted
+ end
+
+ it 'has the status :current' do
+ expect(subject).to have_attributes(status: :current)
+ end
+ end
+ end
+
+ describe 'validations' do
+ subject(:design_at_version) { build(:design_at_version) }
+
+ it { is_expected.to be_valid }
+
+ describe 'a design-at-version without a design' do
+ subject { described_class.new(design: nil, version: build(:design_version)) }
+
+ it { is_expected.to be_invalid }
+
+ it 'mentions the design in the errors' do
+ subject.valid?
+
+ expect(subject.errors[:design]).to be_present
+ end
+ end
+
+ describe 'a design-at-version without a version' do
+ subject { described_class.new(design: build(:design), version: nil) }
+
+ it { is_expected.to be_invalid }
+
+ it 'mentions the version in the errors' do
+ subject.valid?
+
+ expect(subject.errors[:version]).to be_present
+ end
+ end
+
+ describe 'design_and_version_belong_to_the_same_issue' do
+ context 'both design and version are supplied' do
+ subject(:design_at_version) { build(:design_at_version, design: design, version: version) }
+
+ context 'the design belongs to the same issue as the version' do
+ it { is_expected.to be_valid }
+ end
+
+ context 'the design does not belong to the same issue as the version' do
+ let(:design) { create(:design) }
+ let(:version) { create(:design_version) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ context 'the factory is just supplied with a design' do
+ let(:design) { create(:design) }
+
+ subject(:design_at_version) { build(:design_at_version, design: design) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'the factory is just supplied with a version' do
+ let(:version) { create(:design_version) }
+
+ subject(:design_at_version) { build(:design_at_version, version: version) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+
+ describe 'design_and_version_have_issue_id' do
+ subject(:design_at_version) { build(:design_at_version, design: design, version: version) }
+
+ context 'the design has no issue_id, because it is being imported' do
+ let(:design) { create(:design, :importing) }
+
+ it { is_expected.to be_invalid }
+ end
+
+ context 'the version has no issue_id, because it is being imported' do
+ let(:version) { create(:design_version, :importing) }
+
+ it { is_expected.to be_invalid }
+ end
+
+ context 'both the design and the version are being imported' do
+ let(:version) { create(:design_version, :importing) }
+ let(:design) { create(:design, :importing) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+ end
+
+ def id_of(design, version)
+ build(:design_at_version, design: design, version: version).id
+ end
+
+ describe '.instantiate' do
+ context 'when attrs are valid' do
+ subject do
+ described_class.instantiate(design: design, version: version)
+ end
+
+ it { is_expected.to be_a(described_class).and(be_valid) }
+ end
+
+ context 'when attrs are invalid' do
+ subject do
+ described_class.instantiate(
+ design: create(:design),
+ version: create(:design_version)
+ )
+ end
+
+ it 'raises a validation error' do
+ expect { subject }.to raise_error(ActiveModel::ValidationError)
+ end
+ end
+ end
+
+ describe '.lazy_find' do
+ let!(:version_a) do
+ create(:design_version, designs: create_list(:design, 3, issue: issue))
+ end
+ let!(:version_b) do
+ create(:design_version, designs: create_list(:design, 1, issue: issue))
+ end
+ let!(:version_c) do
+ create(:design_version, designs: create_list(:design, 1, issue: issue_b))
+ end
+
+ let(:id_a) { id_of(version_a.designs.first, version_a) }
+ let(:id_b) { id_of(version_a.designs.second, version_a) }
+ let(:id_c) { id_of(version_a.designs.last, version_a) }
+ let(:id_d) { id_of(version_b.designs.first, version_b) }
+ let(:id_e) { id_of(version_c.designs.first, version_c) }
+ let(:bad_id) { id_of(version_c.designs.first, version_a) }
+
+ def find(the_id)
+ described_class.lazy_find(the_id)
+ end
+
+ let(:db_calls) { 2 }
+
+ it 'issues fewer queries than the naive approach would' do
+ expect do
+ dav_a = find(id_a)
+ dav_b = find(id_b)
+ dav_c = find(id_c)
+ dav_d = find(id_d)
+ dav_e = find(id_e)
+ should_not_exist = find(bad_id)
+
+ expect(dav_a.version).to eq(version_a)
+ expect(dav_b.version).to eq(version_a)
+ expect(dav_c.version).to eq(version_a)
+ expect(dav_d.version).to eq(version_b)
+ expect(dav_e.version).to eq(version_c)
+ expect(should_not_exist).not_to be_present
+
+ expect(version_a.designs).to include(dav_a.design, dav_b.design, dav_c.design)
+ expect(version_b.designs).to include(dav_d.design)
+ expect(version_c.designs).to include(dav_e.design)
+ end.not_to exceed_query_limit(db_calls)
+ end
+ end
+
+ describe '.find' do
+ let(:results) { described_class.find(ids) }
+
+ # 2 versions, with 5 total designs on issue A, so 2*5 = 10
+ let!(:version_a) do
+ create(:design_version, designs: create_list(:design, 3, issue: issue))
+ end
+ let!(:version_b) do
+ create(:design_version, designs: create_list(:design, 2, issue: issue))
+ end
+ # 1 version, with 3 designs on issue B, so 1*3 = 3
+ let!(:version_c) do
+ create(:design_version, designs: create_list(:design, 3, issue: issue_b))
+ end
+
+ context 'invalid ids' do
+ let(:ids) do
+ version_b.designs.map { |d| id_of(d, version_c) }
+ end
+
+ describe '#count' do
+ it 'counts 0 records' do
+ expect(results.count).to eq(0)
+ end
+ end
+
+ describe '#empty?' do
+ it 'is empty' do
+ expect(results).to be_empty
+ end
+ end
+
+ describe '#to_a' do
+ it 'finds no records' do
+ expect(results.to_a).to eq([])
+ end
+ end
+ end
+
+ context 'valid ids' do
+ let(:red_herrings) { issue_b.designs.sample(2).map { |d| id_of(d, version_a) } }
+
+ let(:ids) do
+ a_ids = issue.designs.sample(2).map { |d| id_of(d, version_a) }
+ b_ids = issue.designs.sample(2).map { |d| id_of(d, version_b) }
+ c_ids = issue_b.designs.sample(2).map { |d| id_of(d, version_c) }
+
+ a_ids + b_ids + c_ids + red_herrings
+ end
+
+ before do
+ ids.size # force IDs
+ end
+
+ describe '#count' do
+ it 'counts 2 records' do
+ expect(results.count).to eq(6)
+ end
+
+ it 'issues at most two queries' do
+ expect { results.count }.not_to exceed_query_limit(2)
+ end
+ end
+
+ describe '#to_a' do
+ it 'finds 6 records' do
+ expect(results.size).to eq(6)
+ expect(results).to all(be_a(described_class))
+ end
+
+ it 'only returns records with matching IDs' do
+ expect(results.map(&:id)).to match_array(ids - red_herrings)
+ end
+
+ it 'only returns valid records' do
+ expect(results).to all(be_valid)
+ end
+
+ it 'issues at most two queries' do
+ expect { results.to_a }.not_to exceed_query_limit(2)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb
new file mode 100644
index 00000000000..bd48f742042
--- /dev/null
+++ b/spec/models/design_management/design_collection_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::DesignCollection do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue, reload: true) { create(:issue) }
+
+ subject(:collection) { described_class.new(issue) }
+
+ describe ".find_or_create_design!" do
+ it "finds an existing design" do
+ design = create(:design, issue: issue, filename: 'world.png')
+
+ expect(collection.find_or_create_design!(filename: 'world.png')).to eq(design)
+ end
+
+ it "creates a new design if one didn't exist" do
+ expect(issue.designs.size).to eq(0)
+
+ new_design = collection.find_or_create_design!(filename: 'world.png')
+
+ expect(issue.designs.size).to eq(1)
+ expect(new_design.filename).to eq('world.png')
+ expect(new_design.issue).to eq(issue)
+ end
+
+ it "only queries the designs once" do
+ create(:design, issue: issue, filename: 'hello.png')
+ create(:design, issue: issue, filename: 'world.jpg')
+
+ expect do
+ collection.find_or_create_design!(filename: 'hello.png')
+ collection.find_or_create_design!(filename: 'world.jpg')
+ end.not_to exceed_query_limit(1)
+ end
+ end
+
+ describe "#versions" do
+ it "includes versions for all designs" do
+ version_1 = create(:design_version)
+ version_2 = create(:design_version)
+ other_version = create(:design_version)
+ create(:design, issue: issue, versions: [version_1])
+ create(:design, issue: issue, versions: [version_2])
+ create(:design, versions: [other_version])
+
+ expect(collection.versions).to contain_exactly(version_1, version_2)
+ end
+ end
+
+ describe "#repository" do
+ it "builds a design repository" do
+ expect(collection.repository).to be_a(DesignManagement::Repository)
+ end
+ end
+
+ describe '#designs_by_filename' do
+ let(:designs) { create_list(:design, 5, :with_file, issue: issue) }
+ let(:filenames) { designs.map(&:filename) }
+ let(:query) { subject.designs_by_filename(filenames) }
+
+ it 'finds all the designs with those filenames on this issue' do
+ expect(query).to have_attributes(size: 5)
+ end
+
+ it 'only makes a single query' do
+ designs.each(&:id)
+ expect { query }.not_to exceed_query_limit(1)
+ end
+
+ context 'some are deleted' do
+ before do
+ delete_designs(*designs.sample(2))
+ end
+
+ it 'takes deletion into account' do
+ expect(query).to have_attributes(size: 3)
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb
new file mode 100644
index 00000000000..555b7f04f86
--- /dev/null
+++ b/spec/models/design_management/design_spec.rb
@@ -0,0 +1,582 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::Design do
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design1) { create(:design, :with_versions, issue: issue, versions_count: 1) }
+ let_it_be(:design2) { create(:design, :with_versions, issue: issue, versions_count: 1) }
+ let_it_be(:design3) { create(:design, :with_versions, issue: issue, versions_count: 1) }
+ let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) }
+
+ describe 'relations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:issue) }
+ it { is_expected.to have_many(:actions) }
+ it { is_expected.to have_many(:versions) }
+ it { is_expected.to have_many(:notes).dependent(:delete_all) }
+ it { is_expected.to have_many(:user_mentions) }
+ end
+
+ describe 'validations' do
+ subject(:design) { build(:design) }
+
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:issue) }
+ it { is_expected.to validate_presence_of(:filename) }
+ it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) }
+
+ it "validates that the extension is an image" do
+ design.filename = "thing.txt"
+ extensions = described_class::SAFE_IMAGE_EXT + described_class::DANGEROUS_IMAGE_EXT
+
+ expect(design).not_to be_valid
+ expect(design.errors[:filename].first).to eq(
+ "does not have a supported extension. Only #{extensions.to_sentence} are supported"
+ )
+ end
+
+ describe 'validating files with .svg extension' do
+ before do
+ design.filename = "thing.svg"
+ end
+
+ it "allows .svg files when feature flag is enabled" do
+ stub_feature_flags(design_management_allow_dangerous_images: true)
+
+ expect(design).to be_valid
+ end
+
+ it "does not allow .svg files when feature flag is disabled" do
+ stub_feature_flags(design_management_allow_dangerous_images: false)
+
+ expect(design).not_to be_valid
+ expect(design.errors[:filename].first).to eq(
+ "does not have a supported extension. Only #{described_class::SAFE_IMAGE_EXT.to_sentence} are supported"
+ )
+ end
+ end
+ end
+
+ describe 'scopes' do
+ describe '.visible_at_version' do
+ let(:versions) { DesignManagement::Version.where(issue: issue).ordered }
+ let(:found) { described_class.visible_at_version(version) }
+
+ context 'at oldest version' do
+ let(:version) { versions.last }
+
+ it 'finds the first design only' do
+ expect(found).to contain_exactly(design1)
+ end
+ end
+
+ context 'at version 2' do
+ let(:version) { versions.second }
+
+ it 'finds the first and second designs' do
+ expect(found).to contain_exactly(design1, design2)
+ end
+ end
+
+ context 'at latest version' do
+ let(:version) { versions.first }
+
+ it 'finds designs' do
+ expect(found).to contain_exactly(design1, design2, design3)
+ end
+ end
+
+ context 'when the argument is nil' do
+ let(:version) { nil }
+
+ it 'finds all undeleted designs' do
+ expect(found).to contain_exactly(design1, design2, design3)
+ end
+ end
+
+ describe 'one of the designs was deleted before the given version' do
+ before do
+ delete_designs(design2)
+ end
+
+ it 'is not returned' do
+ current_version = versions.first
+
+ expect(described_class.visible_at_version(current_version)).to contain_exactly(design1, design3)
+ end
+ end
+
+ context 'a re-created history' do
+ before do
+ delete_designs(design1, design2)
+ restore_designs(design1)
+ end
+
+ it 'is returned, though other deleted events are not' do
+ expect(described_class.visible_at_version(nil)).to contain_exactly(design1, design3)
+ end
+ end
+
+ # test that a design that has been modified at various points
+ # can be queried for correctly at different points in its history
+ describe 'dead or alive' do
+ let(:versions) { DesignManagement::Version.where(issue: issue).map { |v| [v, :alive] } }
+
+ before do
+ versions << [delete_designs(design1), :dead]
+ versions << [modify_designs(design2), :dead]
+ versions << [restore_designs(design1), :alive]
+ versions << [modify_designs(design3), :alive]
+ versions << [delete_designs(design1), :dead]
+ versions << [modify_designs(design2, design3), :dead]
+ versions << [restore_designs(design1), :alive]
+ end
+
+ it 'can establish the history at any point' do
+ history = versions.map(&:first).map do |v|
+ described_class.visible_at_version(v).include?(design1) ? :alive : :dead
+ end
+
+ expect(history).to eq(versions.map(&:second))
+ end
+ end
+ end
+
+ describe '.with_filename' do
+ it 'returns correct design when passed a single filename' do
+ expect(described_class.with_filename(design1.filename)).to eq([design1])
+ end
+
+ it 'returns correct designs when passed an Array of filenames' do
+ expect(
+ described_class.with_filename([design1, design2].map(&:filename))
+ ).to contain_exactly(design1, design2)
+ end
+ end
+
+ describe '.on_issue' do
+ it 'returns correct designs when passed a single issue' do
+ expect(described_class.on_issue(issue)).to match_array(issue.designs)
+ end
+
+ it 'returns correct designs when passed an Array of issues' do
+ expect(
+ described_class.on_issue([issue, deleted_design.issue])
+ ).to contain_exactly(design1, design2, design3, deleted_design)
+ end
+ end
+
+ describe '.current' do
+ it 'returns just the undeleted designs' do
+ delete_designs(design3)
+
+ expect(described_class.current).to contain_exactly(design1, design2)
+ end
+ end
+ end
+
+ describe '#visible_in?' do
+ let_it_be(:issue) { create(:issue) }
+
+ # It is expensive to re-create complex histories, so we do it once, and then
+ # assert that we can establish visibility at any given version.
+ it 'tells us when a design is visible' do
+ expected = []
+
+ first_design = create(:design, :with_versions, issue: issue, versions_count: 1)
+ prior_to_creation = first_design.versions.first
+ expected << [prior_to_creation, :not_created_yet, false]
+
+ v = modify_designs(first_design)
+ expected << [v, :not_created_yet, false]
+
+ design = create(:design, :with_versions, issue: issue, versions_count: 1)
+ created_in = design.versions.first
+ expected << [created_in, :created, true]
+
+ # The future state should not affect the result for any state, so we
+ # ensure that most states have a long future as well as a rich past
+ 2.times do
+ v = modify_designs(first_design)
+ expected << [v, :unaffected_visible, true]
+
+ v = modify_designs(design)
+ expected << [v, :modified, true]
+
+ v = modify_designs(first_design)
+ expected << [v, :unaffected_visible, true]
+
+ v = delete_designs(design)
+ expected << [v, :deleted, false]
+
+ v = modify_designs(first_design)
+ expected << [v, :unaffected_nv, false]
+
+ v = restore_designs(design)
+ expected << [v, :restored, true]
+ end
+
+ delete_designs(design) # ensure visibility is not corelated with current state
+
+ got = expected.map do |(v, sym, _)|
+ [v, sym, design.visible_in?(v)]
+ end
+
+ expect(got).to eq(expected)
+ end
+ end
+
+ describe '#to_ability_name' do
+ it { expect(described_class.new.to_ability_name).to eq('design') }
+ end
+
+ describe '#status' do
+ context 'the design is new' do
+ subject { build(:design) }
+
+ it { is_expected.to have_attributes(status: :new) }
+ end
+
+ context 'the design is current' do
+ subject { design1 }
+
+ it { is_expected.to have_attributes(status: :current) }
+ end
+
+ context 'the design has been deleted' do
+ subject { deleted_design }
+
+ it { is_expected.to have_attributes(status: :deleted) }
+ end
+ end
+
+ describe '#deleted?' do
+ context 'the design is new' do
+ let(:design) { build(:design) }
+
+ it 'is falsy' do
+ expect(design).not_to be_deleted
+ end
+ end
+
+ context 'the design is current' do
+ let(:design) { design1 }
+
+ it 'is falsy' do
+ expect(design).not_to be_deleted
+ end
+ end
+
+ context 'the design has been deleted' do
+ let(:design) { deleted_design }
+
+ it 'is truthy' do
+ expect(design).to be_deleted
+ end
+ end
+
+ context 'the design has been deleted, but was then re-created' do
+ let(:design) { create(:design, :with_versions, versions_count: 1, deleted: true) }
+
+ it 'is falsy' do
+ restore_designs(design)
+
+ expect(design).not_to be_deleted
+ end
+ end
+ end
+
+ describe "#new_design?" do
+ let(:design) { design1 }
+
+ it "is false when there are versions" do
+ expect(design1).not_to be_new_design
+ end
+
+ it "is true when there are no versions" do
+ expect(build(:design)).to be_new_design
+ end
+
+ it 'is false for deleted designs' do
+ expect(deleted_design).not_to be_new_design
+ end
+
+ it "does not cause extra queries when actions are loaded" do
+ design.actions.map(&:id)
+
+ expect { design.new_design? }.not_to exceed_query_limit(0)
+ end
+
+ it "implicitly caches values" do
+ expect do
+ design.new_design?
+ design.new_design?
+ end.not_to exceed_query_limit(1)
+ end
+
+ it "queries again when the clear_version_cache trigger has been called" do
+ expect do
+ design.new_design?
+ design.clear_version_cache
+ design.new_design?
+ end.not_to exceed_query_limit(2)
+ end
+
+ it "causes a single query when there versions are not loaded" do
+ design.reload
+
+ expect { design.new_design? }.not_to exceed_query_limit(1)
+ end
+ end
+
+ describe "#full_path" do
+ it "builds the full path for a design" do
+ design = build(:design, filename: "hello.jpg")
+ expected_path = "#{DesignManagement.designs_directory}/issue-#{design.issue.iid}/hello.jpg"
+
+ expect(design.full_path).to eq(expected_path)
+ end
+ end
+
+ describe '#diff_refs' do
+ let(:design) { create(:design, :with_file, versions_count: versions_count) }
+
+ context 'there are several versions' do
+ let(:versions_count) { 3 }
+
+ it "builds diff refs based on the first commit and it's for the design" do
+ expect(design.diff_refs.base_sha).to eq(design.versions.ordered.second.sha)
+ expect(design.diff_refs.head_sha).to eq(design.versions.ordered.first.sha)
+ end
+ end
+
+ context 'there is just one version' do
+ let(:versions_count) { 1 }
+
+ it 'builds diff refs based on the empty tree if there was only one version' do
+ design = create(:design, :with_file, versions_count: 1)
+
+ expect(design.diff_refs.base_sha).to eq(Gitlab::Git::BLANK_SHA)
+ expect(design.diff_refs.head_sha).to eq(design.diff_refs.head_sha)
+ end
+ end
+
+ it 'has no diff ref if new' do
+ design = build(:design)
+
+ expect(design.diff_refs).to be_nil
+ end
+ end
+
+ describe '#repository' do
+ it 'is a design repository' do
+ design = build(:design)
+
+ expect(design.repository).to be_a(DesignManagement::Repository)
+ end
+ end
+
+ # TODO these tests are being temporarily skipped unless run in EE,
+ # as we are in the process of moving Design Management to FOSS in 13.0
+ # in steps. In the current step the routes have not yet been moved.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283.
+ describe '#note_etag_key' do
+ it 'returns a correct etag key' do
+ skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
+
+ design = create(:design)
+
+ expect(design.note_etag_key).to eq(
+ ::Gitlab::Routing.url_helpers.designs_project_issue_path(design.project, design.issue, { vueroute: design.filename })
+ )
+ end
+ end
+
+ describe '#user_notes_count', :use_clean_rails_memory_store_caching do
+ let_it_be(:design) { create(:design, :with_file) }
+
+ subject { design.user_notes_count }
+
+ # Note: Cache invalidation tests are in `design_user_notes_count_service_spec.rb`
+
+ it 'returns a count of user-generated notes' do
+ create(:diff_note_on_design, noteable: design)
+
+ is_expected.to eq(1)
+ end
+
+ it 'does not count notes on other designs' do
+ second_design = create(:design, :with_file)
+ create(:diff_note_on_design, noteable: second_design)
+
+ is_expected.to eq(0)
+ end
+
+ it 'does not count system notes' do
+ create(:diff_note_on_design, system: true, noteable: design)
+
+ is_expected.to eq(0)
+ end
+ end
+
+ describe '#after_note_changed' do
+ subject { build(:design) }
+
+ it 'calls #delete_cache on DesignUserNotesCountService' do
+ expect_next_instance_of(DesignManagement::DesignUserNotesCountService) do |service|
+ expect(service).to receive(:delete_cache)
+ end
+
+ subject.after_note_changed(build(:note))
+ end
+
+ it 'does not call #delete_cache on DesignUserNotesCountService when passed a system note' do
+ expect(DesignManagement::DesignUserNotesCountService).not_to receive(:new)
+
+ subject.after_note_changed(build(:note, :system))
+ end
+ end
+
+ describe '.for_reference' do
+ let_it_be(:design_a) { create(:design) }
+ let_it_be(:design_b) { create(:design) }
+
+ it 'avoids extra queries when calling to_reference' do
+ designs = described_class.for_reference.where(id: [design_a.id, design_b.id]).to_a
+
+ expect { designs.map(&:to_reference) }.not_to exceed_query_limit(0)
+ end
+ end
+
+ describe '#to_reference' do
+ let(:namespace) { build(:namespace, path: 'sample-namespace') }
+ let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
+ let(:group) { create(:group, name: 'Group', path: 'sample-group') }
+ let(:issue) { build(:issue, iid: 1, project: project) }
+ let(:filename) { 'homescreen.jpg' }
+ let(:design) { build(:design, filename: filename, issue: issue, project: project) }
+
+ context 'when nil argument' do
+ let(:reference) { design.to_reference }
+
+ it 'uses the simple format' do
+ expect(reference).to eq "#1[homescreen.jpg]"
+ end
+
+ context 'when the filename contains spaces, hyphens, periods, single-quotes, underscores and colons' do
+ let(:filename) { %q{a complex filename: containing - _ : etc., but still 'simple'.gif} }
+
+ it 'uses the simple format' do
+ expect(reference).to eq "#1[#{filename}]"
+ end
+ end
+
+ context 'when the filename contains HTML angle brackets' do
+ let(:filename) { 'a <em>great</em> filename.jpg' }
+
+ it 'uses Base64 encoding' do
+ expect(reference).to eq "#1[base64:#{Base64.strict_encode64(filename)}]"
+ end
+ end
+
+ context 'when the filename contains quotation marks' do
+ let(:filename) { %q{a "great" filename.jpg} }
+
+ it 'uses enclosing quotes, with backslash encoding' do
+ expect(reference).to eq %q{#1["a \"great\" filename.jpg"]}
+ end
+ end
+
+ context 'when the filename contains square brackets' do
+ let(:filename) { %q{a [great] filename.jpg} }
+
+ it 'uses enclosing quotes' do
+ expect(reference).to eq %q{#1["a [great] filename.jpg"]}
+ end
+ end
+ end
+
+ context 'when full is true' do
+ it 'returns complete path to the issue' do
+ refs = [
+ design.to_reference(full: true),
+ design.to_reference(project, full: true),
+ design.to_reference(group, full: true)
+ ]
+
+ expect(refs).to all(eq 'sample-namespace/sample-project#1/designs[homescreen.jpg]')
+ end
+ end
+
+ context 'when full is false' do
+ it 'returns complete path to the issue' do
+ refs = [
+ design.to_reference(build(:project), full: false),
+ design.to_reference(group, full: false)
+ ]
+
+ expect(refs).to all(eq 'sample-namespace/sample-project#1[homescreen.jpg]')
+ end
+ end
+
+ context 'when same project argument' do
+ it 'returns bare reference' do
+ expect(design.to_reference(project)).to eq("#1[homescreen.jpg]")
+ end
+ end
+ end
+
+ describe 'reference_pattern' do
+ let(:match) { described_class.reference_pattern.match(ref) }
+ let(:ref) { design.to_reference }
+ let(:design) { build(:design, filename: filename) }
+
+ context 'simple_file_name' do
+ let(:filename) { 'simple-file-name.jpg' }
+
+ it 'matches :simple_file_name' do
+ expect(match[:simple_file_name]).to eq(filename)
+ end
+ end
+
+ context 'quoted_file_name' do
+ let(:filename) { 'simple "file" name.jpg' }
+
+ it 'matches :simple_file_name' do
+ expect(match[:escaped_filename].gsub(/\\"/, '"')).to eq(filename)
+ end
+ end
+
+ context 'Base64 name' do
+ let(:filename) { '<>.png' }
+
+ it 'matches base_64_encoded_name' do
+ expect(Base64.decode64(match[:base_64_encoded_name])).to eq(filename)
+ end
+ end
+ end
+
+ describe '.by_issue_id_and_filename' do
+ let_it_be(:issue_a) { create(:issue) }
+ let_it_be(:issue_b) { create(:issue) }
+
+ let_it_be(:design_a) { create(:design, issue: issue_a) }
+ let_it_be(:design_b) { create(:design, issue: issue_a) }
+ let_it_be(:design_c) { create(:design, issue: issue_b, filename: design_a.filename) }
+ let_it_be(:design_d) { create(:design, issue: issue_b, filename: design_b.filename) }
+
+ it_behaves_like 'a where_composite scope', :by_issue_id_and_filename do
+ let(:all_results) { [design_a, design_b, design_c, design_d] }
+ let(:first_result) { design_a }
+
+ let(:composite_ids) do
+ all_results.map { |design| { issue_id: design.issue_id, filename: design.filename } }
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/repository_spec.rb b/spec/models/design_management/repository_spec.rb
new file mode 100644
index 00000000000..996316eeec9
--- /dev/null
+++ b/spec/models/design_management/repository_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::Repository do
+ let(:project) { create(:project) }
+ let(:repository) { described_class.new(project) }
+
+ shared_examples 'returns parsed git attributes that enable LFS for all file types' do
+ it do
+ expect(subject.patterns).to be_a_kind_of(Hash)
+ expect(subject.patterns).to have_key('/designs/*')
+ expect(subject.patterns['/designs/*']).to eql(
+ { "filter" => "lfs", "diff" => "lfs", "merge" => "lfs", "text" => false }
+ )
+ end
+ end
+
+ describe "#info_attributes" do
+ subject { repository.info_attributes }
+
+ include_examples 'returns parsed git attributes that enable LFS for all file types'
+ end
+
+ describe '#attributes_at' do
+ subject { repository.attributes_at }
+
+ include_examples 'returns parsed git attributes that enable LFS for all file types'
+ end
+
+ describe '#gitattribute' do
+ it 'returns a gitattribute when path has gitattributes' do
+ expect(repository.gitattribute('/designs/file.txt', 'filter')).to eq('lfs')
+ end
+
+ it 'returns nil when path has no gitattributes' do
+ expect(repository.gitattribute('/invalid/file.txt', 'filter')).to be_nil
+ end
+ end
+
+ describe '#copy_gitattributes' do
+ it 'always returns regardless of whether given a valid or invalid ref' do
+ expect(repository.copy_gitattributes('master')).to be true
+ expect(repository.copy_gitattributes('invalid')).to be true
+ end
+ end
+
+ describe '#attributes' do
+ it 'confirms that all files are LFS enabled' do
+ %w(png zip anything).each do |filetype|
+ path = "/#{DesignManagement.designs_directory}/file.#{filetype}"
+ attributes = repository.attributes(path)
+
+ expect(attributes['filter']).to eq('lfs')
+ end
+ end
+ end
+end
diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb
new file mode 100644
index 00000000000..ab6958ea94a
--- /dev/null
+++ b/spec/models/design_management/version_spec.rb
@@ -0,0 +1,342 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+describe DesignManagement::Version do
+ let_it_be(:issue) { create(:issue) }
+
+ describe 'relations' do
+ it { is_expected.to have_many(:actions) }
+ it { is_expected.to have_many(:designs).through(:actions) }
+
+ it 'constrains the designs relation correctly' do
+ design = create(:design)
+ version = create(:design_version, designs: [design])
+
+ expect { version.designs << design }.to raise_error(ActiveRecord::RecordNotUnique)
+ end
+
+ it 'allows adding multiple versions to a single design' do
+ design = create(:design)
+ versions = create_list(:design_version, 2)
+
+ expect { versions.each { |v| design.versions << v } }
+ .not_to raise_error
+ end
+ end
+
+ describe 'validations' do
+ subject(:design_version) { build(:design_version) }
+
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:author) }
+ it { is_expected.to validate_presence_of(:sha) }
+ it { is_expected.to validate_presence_of(:designs) }
+ it { is_expected.to validate_presence_of(:issue_id) }
+ it { is_expected.to validate_uniqueness_of(:sha).scoped_to(:issue_id).case_insensitive }
+ end
+
+ describe "scopes" do
+ let_it_be(:version_1) { create(:design_version) }
+ let_it_be(:version_2) { create(:design_version) }
+
+ describe ".for_designs" do
+ it "only returns versions related to the specified designs" do
+ _other_version = create(:design_version)
+ designs = [create(:design, versions: [version_1]),
+ create(:design, versions: [version_2])]
+
+ expect(described_class.for_designs(designs))
+ .to contain_exactly(version_1, version_2)
+ end
+ end
+
+ describe '.earlier_or_equal_to' do
+ it 'only returns versions created earlier or later than the given version' do
+ expect(described_class.earlier_or_equal_to(version_1)).to eq([version_1])
+ expect(described_class.earlier_or_equal_to(version_2)).to contain_exactly(version_1, version_2)
+ end
+
+ it 'can be passed either a DesignManagement::Version or an ID' do
+ [version_1, version_1.id].each do |arg|
+ expect(described_class.earlier_or_equal_to(arg)).to eq([version_1])
+ end
+ end
+ end
+
+ describe '.by_sha' do
+ it 'can find versions by sha' do
+ [version_1, version_2].each do |version|
+ expect(described_class.by_sha(version.sha)).to contain_exactly(version)
+ end
+ end
+ end
+ end
+
+ describe ".create_for_designs" do
+ def current_version_id(design)
+ design.send(:head_version).try(:id)
+ end
+
+ def as_actions(designs, action = :create)
+ designs.map do |d|
+ DesignManagement::DesignAction.new(d, action, action == :delete ? nil : :content)
+ end
+ end
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:design_b) { create(:design, issue: issue) }
+ let_it_be(:designs) { [design_a, design_b] }
+
+ describe 'the error raised when there are no actions' do
+ let_it_be(:sha) { 'f00' }
+
+ def call_with_empty_actions
+ described_class.create_for_designs([], sha, author)
+ end
+
+ it 'raises CouldNotCreateVersion' do
+ expect { call_with_empty_actions }
+ .to raise_error(described_class::CouldNotCreateVersion)
+ end
+
+ it 'has an appropriate cause' do
+ expect { call_with_empty_actions }
+ .to raise_error(have_attributes(cause: ActiveRecord::RecordInvalid))
+ end
+
+ it 'provides extra data sentry can consume' do
+ extra_info = a_hash_including(sha: sha)
+
+ expect { call_with_empty_actions }
+ .to raise_error(have_attributes(sentry_extra_data: extra_info))
+ end
+ end
+
+ describe 'the error raised when the designs come from different issues' do
+ let_it_be(:sha) { 'f00' }
+ let_it_be(:designs) { create_list(:design, 2) }
+ let_it_be(:actions) { as_actions(designs) }
+
+ def call_with_mismatched_designs
+ described_class.create_for_designs(actions, sha, author)
+ end
+
+ it 'raises CouldNotCreateVersion' do
+ expect { call_with_mismatched_designs }
+ .to raise_error(described_class::CouldNotCreateVersion)
+ end
+
+ it 'has an appropriate cause' do
+ expect { call_with_mismatched_designs }
+ .to raise_error(have_attributes(cause: described_class::NotSameIssue))
+ end
+
+ it 'provides extra data sentry can consume' do
+ extra_info = a_hash_including(design_ids: designs.map(&:id))
+
+ expect { call_with_mismatched_designs }
+ .to raise_error(have_attributes(sentry_extra_data: extra_info))
+ end
+ end
+
+ it 'does not leave invalid versions around if creation fails' do
+ expect do
+ described_class.create_for_designs([], 'abcdef', author) rescue nil
+ end.not_to change { described_class.count }
+ end
+
+ it 'does not leave orphaned design-versions around if creation fails' do
+ actions = as_actions(designs)
+ expect do
+ described_class.create_for_designs(actions, '', author) rescue nil
+ end.not_to change { DesignManagement::Action.count }
+ end
+
+ it 'creates a version and links it to multiple designs' do
+ actions = as_actions(designs, :create)
+
+ version = described_class.create_for_designs(actions, 'abc', author)
+
+ expect(version.designs).to contain_exactly(*designs)
+ expect(designs.map(&method(:current_version_id))).to all(eq version.id)
+ end
+
+ it 'creates designs if they are new to git' do
+ actions = as_actions(designs, :create)
+
+ described_class.create_for_designs(actions, 'abc', author)
+
+ expect(designs.map(&:most_recent_action)).to all(be_creation)
+ end
+
+ it 'correctly associates the version with the issue' do
+ actions = as_actions(designs)
+
+ version = described_class.create_for_designs(actions, 'abc', author)
+
+ expect(version.issue).to eq(issue)
+ end
+
+ it 'correctly associates the version with the author' do
+ actions = as_actions(designs)
+
+ version = described_class.create_for_designs(actions, 'abc', author)
+
+ expect(version.author).to eq(author)
+ end
+
+ it 'modifies designs if git updated them' do
+ actions = as_actions(designs, :update)
+
+ described_class.create_for_designs(actions, 'abc', author)
+
+ expect(designs.map(&:most_recent_action)).to all(be_modification)
+ end
+
+ it 'deletes designs when the git action was delete' do
+ actions = as_actions(designs, :delete)
+
+ described_class.create_for_designs(actions, 'def', author)
+
+ expect(designs).to all(be_deleted)
+ end
+
+ it 're-creates designs if they are deleted' do
+ described_class.create_for_designs(as_actions(designs, :create), 'abc', author)
+ described_class.create_for_designs(as_actions(designs, :delete), 'def', author)
+
+ expect(designs).to all(be_deleted)
+
+ described_class.create_for_designs(as_actions(designs, :create), 'ghi', author)
+
+ expect(designs.map(&:most_recent_action)).to all(be_creation)
+ expect(designs).not_to include(be_deleted)
+ end
+
+ it 'changes the version of the designs' do
+ actions = as_actions([design_a])
+ described_class.create_for_designs(actions, 'before', author)
+
+ expect do
+ described_class.create_for_designs(actions, 'after', author)
+ end.to change { current_version_id(design_a) }
+ end
+ end
+
+ describe '#designs_by_event' do
+ context 'there is a single design' do
+ let_it_be(:design) { create(:design) }
+
+ shared_examples :a_correctly_categorised_design do |kind, category|
+ let_it_be(:version) { create(:design_version, kind => [design]) }
+
+ it 'returns a hash with a single key and the single design in that bucket' do
+ expect(version.designs_by_event).to eq(category => [design])
+ end
+ end
+
+ it_behaves_like :a_correctly_categorised_design, :created_designs, 'creation'
+ it_behaves_like :a_correctly_categorised_design, :modified_designs, 'modification'
+ it_behaves_like :a_correctly_categorised_design, :deleted_designs, 'deletion'
+ end
+
+ context 'there are a bunch of different designs in a variety of states' do
+ let_it_be(:version) do
+ create(:design_version,
+ created_designs: create_list(:design, 3),
+ modified_designs: create_list(:design, 4),
+ deleted_designs: create_list(:design, 5))
+ end
+
+ it 'puts them in the right buckets' do
+ expect(version.designs_by_event).to match(
+ a_hash_including(
+ 'creation' => have_attributes(size: 3),
+ 'modification' => have_attributes(size: 4),
+ 'deletion' => have_attributes(size: 5)
+ )
+ )
+ end
+
+ it 'does not suffer from N+1 queries' do
+ version.designs.map(&:id) # we don't care about the set-up queries
+ expect { version.designs_by_event }.not_to exceed_query_limit(2)
+ end
+ end
+ end
+
+ describe '#author' do
+ it 'returns the author' do
+ author = build(:user)
+ version = build(:design_version, author: author)
+
+ expect(version.author).to eq(author)
+ end
+
+ it 'returns nil if author_id is nil and version is not persisted' do
+ version = build(:design_version, author: nil)
+
+ expect(version.author).to eq(nil)
+ end
+
+ it 'retrieves author from the Commit if author_id is nil and version has been persisted' do
+ author = create(:user)
+ version = create(:design_version, :committed, author: author)
+ author.destroy
+ version.reload
+ commit = version.issue.project.design_repository.commit(version.sha)
+ commit_user = create(:user, email: commit.author_email, name: commit.author_name)
+
+ expect(version.author_id).to eq(nil)
+ expect(version.author).to eq(commit_user)
+ end
+ end
+
+ describe '#diff_refs' do
+ let(:project) { issue.project }
+
+ before do
+ expect(project.design_repository).to receive(:commit)
+ .once
+ .with(sha)
+ .and_return(commit)
+ end
+
+ subject { create(:design_version, issue: issue, sha: sha) }
+
+ context 'there is a commit in the repo by the SHA' do
+ let(:commit) { build(:commit) }
+ let(:sha) { commit.id }
+
+ it { is_expected.to have_attributes(diff_refs: commit.diff_refs) }
+
+ it 'memoizes calls to #diff_refs' do
+ expect(subject.diff_refs).to eq(subject.diff_refs)
+ end
+ end
+
+ context 'there is no commit in the repo by the SHA' do
+ let(:commit) { nil }
+ let(:sha) { Digest::SHA1.hexdigest("points to nothing") }
+
+ it { is_expected.to have_attributes(diff_refs: be_nil) }
+ end
+ end
+
+ describe '#reset' do
+ subject { create(:design_version, issue: issue) }
+
+ it 'removes memoized values' do
+ expect(subject).to receive(:commit).twice.and_return(nil)
+
+ subject.diff_refs
+ subject.diff_refs
+
+ subject.reset
+
+ subject.diff_refs
+ subject.diff_refs
+ end
+ end
+end
diff --git a/spec/models/design_user_mention_spec.rb b/spec/models/design_user_mention_spec.rb
new file mode 100644
index 00000000000..03c77c73c8d
--- /dev/null
+++ b/spec/models/design_user_mention_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignUserMention do
+ describe 'associations' do
+ it { is_expected.to belong_to(:design) }
+ it { is_expected.to belong_to(:note) }
+ end
+
+ it_behaves_like 'has user mentions'
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index b802c8ba506..65f06a5b270 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -287,6 +287,24 @@ describe DiffNote do
reply_diff_note.reload.diff_file
end
end
+
+ context 'when noteable is a Design' do
+ it 'does not return a diff file' do
+ diff_note = create(:diff_note_on_design)
+
+ expect(diff_note.diff_file).to be_nil
+ end
+ end
+ end
+
+ describe '#latest_diff_file' do
+ context 'when noteable is a Design' do
+ it 'does not return a diff file' do
+ diff_note = create(:diff_note_on_design)
+
+ expect(diff_note.latest_diff_file).to be_nil
+ end
+ end
end
describe "#diff_line" do
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 375c8c1546f..cc7dffb93d2 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -15,8 +15,20 @@ describe Issue do
it { is_expected.to belong_to(:closed_by).class_name('User') }
it { is_expected.to have_many(:assignees) }
it { is_expected.to have_many(:user_mentions).class_name("IssueUserMention") }
+ it { is_expected.to have_many(:designs) }
+ it { is_expected.to have_many(:design_versions) }
it { is_expected.to have_one(:sentry_issue) }
it { is_expected.to have_one(:alert_management_alert) }
+
+ describe 'versions.most_recent' do
+ it 'returns the most recent version' do
+ issue = create(:issue)
+ create_list(:design_version, 2, issue: issue)
+ last_version = create(:design_version, issue: issue)
+
+ expect(issue.design_versions.most_recent).to eq(last_version)
+ end
+ end
end
describe 'modules' do
@@ -970,4 +982,48 @@ describe Issue do
expect(issue.previous_updated_at).to eq(Time.new(2013, 02, 06))
end
end
+
+ describe '#design_collection' do
+ it 'returns a design collection' do
+ issue = build(:issue)
+ collection = issue.design_collection
+
+ expect(collection).to be_a(DesignManagement::DesignCollection)
+ expect(collection.issue).to eq(issue)
+ end
+ end
+
+ describe 'current designs' do
+ let(:issue) { create(:issue) }
+
+ subject { issue.designs.current }
+
+ context 'an issue has no designs' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'an issue only has current designs' do
+ let!(:design_a) { create(:design, :with_file, issue: issue) }
+ let!(:design_b) { create(:design, :with_file, issue: issue) }
+ let!(:design_c) { create(:design, :with_file, issue: issue) }
+
+ it { is_expected.to include(design_a, design_b, design_c) }
+ end
+
+ context 'an issue only has deleted designs' do
+ let!(:design_a) { create(:design, :with_file, issue: issue, deleted: true) }
+ let!(:design_b) { create(:design, :with_file, issue: issue, deleted: true) }
+ let!(:design_c) { create(:design, :with_file, issue: issue, deleted: true) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'an issue has a mixture of current and deleted designs' do
+ let!(:design_a) { create(:design, :with_file, issue: issue) }
+ let!(:design_b) { create(:design, :with_file, issue: issue, deleted: true) }
+ let!(:design_c) { create(:design, :with_file, issue: issue) }
+
+ it { is_expected.to contain_exactly(design_a, design_c) }
+ end
+ end
end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 74ec74e0def..4900743ccb5 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -751,6 +751,14 @@ describe Note do
end
end
+ describe '#for_design' do
+ it 'is true when the noteable is a design' do
+ note = build(:note, noteable: build(:design))
+
+ expect(note).to be_for_design
+ end
+ end
+
describe '#to_ability_name' do
it 'returns note' do
expect(build(:note).to_ability_name).to eq('note')
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 78c0e8aef1a..bcd28538e2c 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -6,6 +6,7 @@ describe Project do
include ProjectForksHelper
include GitHelpers
include ExternalAuthorizationServiceHelpers
+ using RSpec::Parameterized::TableSyntax
it_behaves_like 'having unique enum values'
@@ -6058,6 +6059,28 @@ describe Project do
end
end
+ describe '#design_management_enabled?' do
+ let(:project) { build(:project) }
+
+ where(:lfs_enabled, :hashed_storage_enabled, :expectation) do
+ false | false | false
+ true | false | false
+ false | true | false
+ true | true | true
+ end
+
+ with_them do
+ before do
+ expect(project).to receive(:lfs_enabled?).and_return(lfs_enabled)
+ allow(project).to receive(:hashed_storage?).with(:repository).and_return(hashed_storage_enabled)
+ end
+
+ it do
+ expect(project.design_management_enabled?).to be(expectation)
+ end
+ end
+ end
+
def finish_job(export_job)
export_job.start
export_job.finish
diff --git a/spec/services/design_management/design_user_notes_count_service_spec.rb b/spec/services/design_management/design_user_notes_count_service_spec.rb
new file mode 100644
index 00000000000..f4808542995
--- /dev/null
+++ b/spec/services/design_management/design_user_notes_count_service_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignUserNotesCountService, :use_clean_rails_memory_store_caching do
+ let_it_be(:design) { create(:design, :with_file) }
+
+ subject { described_class.new(design) }
+
+ it_behaves_like 'a counter caching service'
+
+ describe '#count' do
+ it 'returns the count of notes' do
+ create_list(:diff_note_on_design, 3, noteable: design)
+
+ expect(subject.count).to eq(3)
+ end
+ end
+
+ describe '#cache_key' do
+ it 'contains the `VERSION` and `design.id`' do
+ expect(subject.cache_key).to eq(['designs', 'notes_count', DesignManagement::DesignUserNotesCountService::VERSION, design.id])
+ end
+ end
+
+ # TODO These tests are being temporarily skipped unless run in EE,
+ # as we are in the process of moving Design Management to FOSS in 13.0
+ # in steps. In the current step the services have not yet been moved.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283.
+ describe 'cache invalidation' do
+ it 'changes when a new note is created' do
+ skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
+
+ new_note_attrs = attributes_for(:diff_note_on_design, noteable: design)
+
+ expect do
+ Notes::CreateService.new(design.project, create(:user), new_note_attrs).execute
+ end.to change { subject.count }.by(1)
+ end
+
+ it 'changes when a note is destroyed' do
+ skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee?
+
+ note = create(:diff_note_on_design, noteable: design)
+
+ expect do
+ Notes::DestroyService.new(note.project, note.author).execute(note)
+ end.to change { subject.count }.by(-1)
+ end
+ end
+end
diff --git a/spec/services/issues/related_branches_service_spec.rb b/spec/services/issues/related_branches_service_spec.rb
index eae35f12560..9f72e499414 100644
--- a/spec/services/issues/related_branches_service_spec.rb
+++ b/spec/services/issues/related_branches_service_spec.rb
@@ -3,39 +3,103 @@
require 'spec_helper'
describe Issues::RelatedBranchesService do
- let(:user) { create(:admin) }
- let(:issue) { create(:issue) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let(:user) { developer }
subject { described_class.new(issue.project, user) }
+ before do
+ issue.project.add_developer(developer)
+ end
+
describe '#execute' do
+ let(:sha) { 'abcdef' }
+ let(:repo) { issue.project.repository }
+ let(:project) { issue.project }
+ let(:branch_info) { subject.execute(issue) }
+
+ def make_branch
+ double('Branch', dereferenced_target: double('Target', sha: sha))
+ end
+
before do
- allow(issue.project.repository).to receive(:branch_names).and_return(["mpempe", "#{issue.iid}mepmep", issue.to_branch_name, "#{issue.iid}-branch"])
+ allow(repo).to receive(:branch_names).and_return(branch_names)
end
- it "selects the right branches when there are no referenced merge requests" do
- expect(subject.execute(issue)).to eq([issue.to_branch_name, "#{issue.iid}-branch"])
+ context 'no branches are available' do
+ let(:branch_names) { [] }
+
+ it 'returns an empty array' do
+ expect(branch_info).to be_empty
+ end
end
- it "selects the right branches when there is a referenced merge request" do
- merge_request = create(:merge_request, { description: "Closes ##{issue.iid}",
- source_project: issue.project,
- source_branch: "#{issue.iid}-branch" })
- merge_request.create_cross_references!(user)
+ context 'branches are available' do
+ let(:missing_branch) { "#{issue.to_branch_name}-missing" }
+ let(:unreadable_branch_name) { "#{issue.to_branch_name}-unreadable" }
+ let(:pipeline) { build(:ci_pipeline, :success, project: project) }
+ let(:unreadable_pipeline) { build(:ci_pipeline, :running) }
+
+ let(:branch_names) do
+ [
+ generate(:branch),
+ "#{issue.iid}doesnt-match",
+ issue.to_branch_name,
+ missing_branch,
+ unreadable_branch_name
+ ]
+ end
+
+ before do
+ {
+ issue.to_branch_name => pipeline,
+ unreadable_branch_name => unreadable_pipeline
+ }.each do |name, pipeline|
+ allow(repo).to receive(:find_branch).with(name).and_return(make_branch)
+ allow(project).to receive(:pipeline_for).with(name, sha).and_return(pipeline)
+ end
+
+ allow(repo).to receive(:find_branch).with(missing_branch).and_return(nil)
+ end
+
+ it 'selects relevant branches, along with pipeline status where available' do
+ expect(branch_info).to contain_exactly(
+ { name: issue.to_branch_name, pipeline_status: an_instance_of(Gitlab::Ci::Status::Success) },
+ { name: missing_branch, pipeline_status: be_nil },
+ { name: unreadable_branch_name, pipeline_status: be_nil }
+ )
+ end
+
+ context 'the user has access to otherwise unreadable pipelines' do
+ let(:user) { create(:admin) }
+
+ it 'returns info a developer could not see' do
+ expect(branch_info.pluck(:pipeline_status)).to include(an_instance_of(Gitlab::Ci::Status::Running))
+ end
+ end
+
+ it 'excludes branches referenced in merge requests' do
+ merge_request = create(:merge_request, { description: "Closes #{issue.to_reference}",
+ source_project: issue.project,
+ source_branch: issue.to_branch_name })
+ merge_request.create_cross_references!(user)
- referenced_merge_requests = Issues::ReferencedMergeRequestsService
- .new(issue.project, user)
- .referenced_merge_requests(issue)
+ referenced_merge_requests = Issues::ReferencedMergeRequestsService
+ .new(issue.project, user)
+ .referenced_merge_requests(issue)
- expect(referenced_merge_requests).not_to be_empty
- expect(subject.execute(issue)).to eq([issue.to_branch_name])
+ expect(referenced_merge_requests).not_to be_empty
+ expect(branch_info.pluck(:name)).not_to include(merge_request.source_branch)
+ end
end
- it 'excludes stable branches from the related branches' do
- allow(issue.project.repository).to receive(:branch_names)
- .and_return(["#{issue.iid}-0-stable"])
+ context 'one of the branches is stable' do
+ let(:branch_names) { ["#{issue.iid}-0-stable"] }
- expect(subject.execute(issue)).to eq []
+ it 'is excluded' do
+ expect(branch_info).to be_empty
+ end
end
end
end
diff --git a/spec/support/helpers/design_management_test_helpers.rb b/spec/support/helpers/design_management_test_helpers.rb
new file mode 100644
index 00000000000..bf41e2f5079
--- /dev/null
+++ b/spec/support/helpers/design_management_test_helpers.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module DesignManagementTestHelpers
+ def enable_design_management(enabled = true, ref_filter = true)
+ stub_lfs_setting(enabled: enabled)
+ stub_feature_flags(design_management_reference_filter_gfm_pipeline: ref_filter)
+ end
+
+ def delete_designs(*designs)
+ act_on_designs(designs) { ::DesignManagement::Action.deletion }
+ end
+
+ def restore_designs(*designs)
+ act_on_designs(designs) { ::DesignManagement::Action.creation }
+ end
+
+ def modify_designs(*designs)
+ act_on_designs(designs) { ::DesignManagement::Action.modification }
+ end
+
+ def path_for_design(design)
+ path_options = { vueroute: design.filename }
+ Gitlab::Routing.url_helpers.designs_project_issue_path(design.project, design.issue, path_options)
+ end
+
+ def url_for_design(design)
+ path_options = { vueroute: design.filename }
+ Gitlab::Routing.url_helpers.designs_project_issue_url(design.project, design.issue, path_options)
+ end
+
+ def url_for_designs(issue)
+ Gitlab::Routing.url_helpers.designs_project_issue_url(issue.project, issue)
+ end
+
+ private
+
+ def act_on_designs(designs, &block)
+ issue = designs.first.issue
+ version = build(:design_version, :empty, issue: issue).tap { |v| v.save(validate: false) }
+ designs.each do |d|
+ yield.create(design: d, version: version)
+ end
+ version
+ end
+end
diff --git a/spec/uploaders/design_management/design_v432x230_uploader_spec.rb b/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
new file mode 100644
index 00000000000..8c62b6ad6a8
--- /dev/null
+++ b/spec/uploaders/design_management/design_v432x230_uploader_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe DesignManagement::DesignV432x230Uploader do
+ include CarrierWave::Test::Matchers
+
+ let(:model) { create(:design_action, :with_image_v432x230) }
+ let(:upload) { create(:upload, :design_action_image_v432x230_upload, model: model) }
+
+ subject(:uploader) { described_class.new(model, :image_v432x230) }
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[uploads/-/system/design_management/action/image_v432x230/],
+ upload_path: %r[uploads/-/system/design_management/action/image_v432x230/],
+ relative_path: %r[uploads/-/system/design_management/action/image_v432x230/],
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/design_management/action/image_v432x230/]
+
+ context 'object_store is REMOTE' do
+ before do
+ stub_uploads_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like 'builds correct paths',
+ store_dir: %r[design_management/action/image_v432x230/],
+ upload_path: %r[design_management/action/image_v432x230/],
+ relative_path: %r[design_management/action/image_v432x230/]
+ end
+
+ describe "#migrate!" do
+ before do
+ uploader.store!(fixture_file_upload('spec/fixtures/dk.png'))
+ stub_uploads_object_storage
+ end
+
+ it_behaves_like 'migrates', to_store: described_class::Store::REMOTE
+ it_behaves_like 'migrates', from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL
+ end
+
+ it 'resizes images', :aggregate_failures do
+ image_loader = CarrierWave::Test::Matchers::ImageLoader
+ original_file = fixture_file_upload('spec/fixtures/dk.png')
+ uploader.store!(original_file)
+
+ expect(
+ image_loader.load_image(original_file.tempfile.path)
+ ).to have_attributes(
+ width: 460,
+ height: 322
+ )
+ expect(
+ image_loader.load_image(uploader.file.file)
+ ).to have_attributes(
+ width: 329,
+ height: 230
+ )
+ end
+
+ context 'accept whitelist file content type' do
+ # We need to feed through a valid path, but we force the parsed mime type
+ # in a stub below so we can set any path.
+ let_it_be(:path) { File.join('spec', 'fixtures', 'dk.png') }
+
+ where(:mime_type) { described_class::MIME_TYPE_WHITELIST }
+
+ with_them do
+ include_context 'force content type detection to mime_type'
+
+ it_behaves_like 'accepted carrierwave upload'
+ end
+ end
+
+ context 'upload non-whitelisted file content type' do
+ let_it_be(:path) { File.join('spec', 'fixtures', 'logo_sample.svg') }
+
+ it_behaves_like 'denied carrierwave upload'
+ end
+
+ context 'upload misnamed non-whitelisted file content type' do
+ let_it_be(:path) { File.join('spec', 'fixtures', 'not_a_png.png') }
+
+ it_behaves_like 'denied carrierwave upload'
+ end
+end
diff --git a/spec/views/projects/issues/_related_branches.html.haml_spec.rb b/spec/views/projects/issues/_related_branches.html.haml_spec.rb
index a6817e3fdbf..6c9bbaea38c 100644
--- a/spec/views/projects/issues/_related_branches.html.haml_spec.rb
+++ b/spec/views/projects/issues/_related_branches.html.haml_spec.rb
@@ -5,23 +5,25 @@ require 'spec_helper'
describe 'projects/issues/_related_branches' do
include Devise::Test::ControllerHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:branch) { project.repository.find_branch('feature') }
- let!(:pipeline) { create(:ci_pipeline, project: project, sha: branch.dereferenced_target.id, ref: 'feature') }
+ let(:pipeline) { build(:ci_pipeline, :success) }
+ let(:status) { pipeline.detailed_status(build(:user)) }
before do
- assign(:project, project)
- assign(:related_branches, ['feature'])
-
- project.add_developer(user)
- allow(view).to receive(:current_user).and_return(user)
+ assign(:related_branches, [
+ { name: 'other', link: 'link-to-other', pipeline_status: nil },
+ { name: 'feature', link: 'link-to-feature', pipeline_status: status }
+ ])
render
end
- it 'shows the related branches with their build status' do
- expect(rendered).to match('feature')
+ it 'shows the related branches with their build status', :aggregate_failures do
+ expect(rendered).to have_text('feature')
+ expect(rendered).to have_text('other')
+ expect(rendered).to have_link(href: 'link-to-feature')
+ expect(rendered).to have_link(href: 'link-to-other')
expect(rendered).to have_css('.related-branch-ci-status')
+ expect(rendered).to have_css('.ci-status-icon')
+ expect(rendered).to have_css('.related-branch-info')
end
end