diff --git a/Gemfile b/Gemfile index 412ea4261319..4d034992f05e 100644 --- a/Gemfile +++ b/Gemfile @@ -208,7 +208,7 @@ gem "aws-sdk-core", "~> 3.244" # File upload via fog + screenshots on travis gem "aws-sdk-s3", "~> 1.217" -gem "openproject-token", "~> 8.8.2" +gem "openproject-token", "~> 8.9.0" gem "plaintext", "~> 0.3.7" diff --git a/Gemfile.lock b/Gemfile.lock index 73046f9e47a9..b2f8f662d035 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -913,7 +913,7 @@ GEM activesupport (>= 7.2.0) openproject-octicons (>= 19.34.0) view_component (>= 3.1, < 5.0) - openproject-token (8.8.2) + openproject-token (8.9.0) activemodel openssl (4.0.1) openssl-signature_algorithm (1.3.0) @@ -1692,7 +1692,7 @@ DEPENDENCIES openproject-reporting! openproject-storages! openproject-team_planner! - openproject-token (~> 8.8.2) + openproject-token (~> 8.9.0) openproject-two_factor_authentication! openproject-webhooks! openproject-wikis! @@ -2074,7 +2074,7 @@ CHECKSUMS openproject-reporting (1.0.0) openproject-storages (1.0.0) openproject-team_planner (1.0.0) - openproject-token (8.8.2) sha256=081cbff7269d92a82fa1d63e9e09c87b70d47d7aefadcbb80d1e7368bc2cf096 + openproject-token (8.9.0) sha256=aa08c144889010750de4edaf61f8614ccb82ac6c63beef1d3a21c6a222358605 openproject-two_factor_authentication (1.0.0) openproject-webhooks (1.0.0) openproject-wikis (1.0.0) diff --git a/app/assets/videos/enterprise/sprint-sharing.mp4 b/app/assets/videos/enterprise/sprint-sharing.mp4 new file mode 100644 index 000000000000..16f5af261f66 Binary files /dev/null and b/app/assets/videos/enterprise/sprint-sharing.mp4 differ diff --git a/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.html.erb b/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.html.erb index 0e5b2c1050aa..03bae13cf228 100644 --- a/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.html.erb +++ b/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.html.erb @@ -29,7 +29,11 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::Beta::Text.new(tag: :div, color: :muted, mb: 3)) do - I18n.t("backlogs.sharing_description") + if only_fallback_allowed + t(".sharing_fallback_description") + else + t(".sharing_description") + end end %> @@ -40,6 +44,6 @@ See COPYRIGHT and LICENSE files for more details. method: :patch, data: { turbo_frame: "_top", controller: "show-when-value-selected" } ) do |f| - render(Projects::Settings::Backlogs::SharingForm.new(f)) + render(Projects::Settings::Backlogs::SharingForm.new(f, only_fallback_allowed:)) end %> diff --git a/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.rb b/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.rb index 94847d32bca1..db1dc9212811 100644 --- a/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.rb +++ b/modules/backlogs/app/components/projects/settings/backlogs/sharing_form_component.rb @@ -35,15 +35,17 @@ class SharingFormComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - def initialize(project:) + def initialize(project:, + only_fallback_allowed: false) super @project = project + @only_fallback_allowed = only_fallback_allowed end private - attr_reader :project + attr_reader :project, :only_fallback_allowed end end end diff --git a/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb b/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb index ff6ee0655174..fdda479043e8 100644 --- a/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb +++ b/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb @@ -41,6 +41,7 @@ class BacklogSettingsContract < ::ModelContract validate :validate_global_sprint_sharer_uniqueness validates :sprint_sharing, presence: true validates :sprint_sharing, inclusion: { in: Project::SPRINT_SHARING_MODES }, allow_blank: true + validate :validate_sprint_sharing_in_ee_token def validate_model? = false @@ -64,5 +65,19 @@ def validate_global_sprint_sharer_uniqueness end end end + + def validate_sprint_sharing_in_ee_token + if !model.not_sharing_sprints? && + !EnterpriseToken.allows_to?(:sprint_sharing) && + sprint_sharing_changed? + errors.add :sprint_sharing, + :enterprise_plan_required, + plan_name: I18n.t("ee.upsell.plan_name", plan: OpenProject::Token.lowest_plan_for(:sprint_sharing)) + end + end + + def sprint_sharing_changed? + model.settings_change&.any? { it.key?("sprint_sharing") } + end end end diff --git a/modules/backlogs/app/forms/projects/settings/backlogs/sharing_form.rb b/modules/backlogs/app/forms/projects/settings/backlogs/sharing_form.rb index 38eac5f43c51..767a7762b77b 100644 --- a/modules/backlogs/app/forms/projects/settings/backlogs/sharing_form.rb +++ b/modules/backlogs/app/forms/projects/settings/backlogs/sharing_form.rb @@ -36,27 +36,47 @@ class SharingForm < ApplicationForm # TODO: Remove this hidden field, once the `radio_button_group` supports rendering # the hidden empty field. # The purpose of the hidden field is to ensure we submit the `sprint_sharing` field - # even if no radio button is chosen. Otherwise, the submitted form will not include + # even if: + # * no radio button is chosen. + # * the selected option is disabled because of a missing EE token. + # Otherwise, the submitted form will not include # the field at all and the save request will return success when in fact no setting # is saved. # Ideally the hidden field should automatically be rendered by the `radio_button_group` # helper, similar to how the `collection_radio_buttons` rails helper does. - sharing_form.hidden(name: :sprint_sharing, value: "") + sharing_form.hidden(name: :sprint_sharing, value: model.sprint_sharing) sharing_form.radio_button_group( name: :sprint_sharing, label: I18n.t("projects.settings.backlog_sharing.sprint_sharing") ) do |group| - Project::SPRINT_SHARING_MODES.each do |option| - group.radio_button( - label: sharing_option_text(option, :label), - value: option, - checked: checked?(option), - disabled: disabled?(option), - caption: caption_for(option), - data: { "show-when-value-selected-target": "cause" } - ) - end + group.radio_button( + label: sharing_option_label(Project::NO_SHARING), + value: Project::NO_SHARING, + caption: sharing_option_caption(Project::NO_SHARING), + data: { "show-when-value-selected-target": "cause" } + ) + group.radio_button( + label: sharing_option_label(Project::SHARE_ALL_PROJECTS), + value: Project::SHARE_ALL_PROJECTS, + disabled: only_fallback_allowed || share_all_projects_disabled?, + caption: shared_all_projects_caption, + data: { "show-when-value-selected-target": "cause" } + ) + group.radio_button( + label: sharing_option_label(Project::SHARE_SUBPROJECTS), + value: Project::SHARE_SUBPROJECTS, + disabled: only_fallback_allowed, + caption: sharing_option_caption(Project::SHARE_SUBPROJECTS), + data: { "show-when-value-selected-target": "cause" } + ) + group.radio_button( + label: sharing_option_label(Project::RECEIVE_SHARED), + value: Project::RECEIVE_SHARED, + disabled: only_fallback_allowed, + caption: sharing_option_caption(Project::RECEIVE_SHARED), + data: { "show-when-value-selected-target": "cause" } + ) end sharing_form.html_content { banner_for(Project::SHARE_SUBPROJECTS, type: :info) } @@ -69,32 +89,27 @@ class SharingForm < ApplicationForm ) end + def initialize(only_fallback_allowed: false) + super() + @only_fallback_allowed = only_fallback_allowed + end + private - def checked?(option) - option == model.sprint_sharing + attr_reader :only_fallback_allowed + + def sharing_option_caption(option) + sharing_option_text(option, :caption) end - def disabled?(option) - option == Project::SHARE_ALL_PROJECTS && share_all_projects_disabled? + def sharing_option_label(option) + sharing_option_text(option, :label) end def sharing_option_text(option, key, **) I18n.t("projects.settings.backlog_sharing.options.#{option}.#{key}", **) end - def caption_for(option) - if disabled?(option) - if User.current.allowed_in_project?(:view_project, global_sprint_sharer) - sharing_option_text(option, :disabled_caption, name: global_sprint_sharer.name) - else - sharing_option_text(option, :disabled_caption_anonymous) - end - else - sharing_option_text(option, :caption) - end - end - def share_all_projects_disabled? global_sprint_sharer && global_sprint_sharer != model end @@ -117,6 +132,16 @@ def banner_for(option, type: :info) end end end + + def shared_all_projects_caption + if !only_fallback_allowed && !share_all_projects_disabled? + sharing_option_caption(Project::SHARE_ALL_PROJECTS) + elsif User.current.allowed_in_project?(:view_project, global_sprint_sharer) + sharing_option_text(Project::SHARE_ALL_PROJECTS, :disabled_caption, name: global_sprint_sharer.name) + else + sharing_option_text(Project::SHARE_ALL_PROJECTS, :disabled_caption_anonymous) + end + end end end end diff --git a/modules/backlogs/app/views/projects/settings/backlog_sharings/show.html.erb b/modules/backlogs/app/views/projects/settings/backlog_sharings/show.html.erb index 9bd548440053..3c18055a4492 100644 --- a/modules/backlogs/app/views/projects/settings/backlog_sharings/show.html.erb +++ b/modules/backlogs/app/views/projects/settings/backlog_sharings/show.html.erb @@ -35,5 +35,18 @@ See COPYRIGHT and LICENSE files for more details. ) ) %> - <%= render(Projects::Settings::Backlogs::SharingFormComponent.new(project: @project)) %> + <% with_enterprise_banner_guard( + :sprint_sharing, + inactive_guard: !@project.not_sharing_sprints?, + variant: :large, + video: "enterprise/sprint-sharing.mp4" + ) do %> + <%= render(EnterpriseEdition::BannerComponent.new(:sprint_sharing, variant: :inline)) %> + <%= render( + Projects::Settings::Backlogs::SharingFormComponent.new( + project: @project, + only_fallback_allowed: !EnterpriseToken.allows_to?(:sprint_sharing) + ) + ) %> + <% end %> <% end %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index c3dfa0d0da13..e35dcb216c0f 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -98,7 +98,6 @@ en: definition_of_done: "Definition of Done" definition_of_done_caption: "Work packages with these statuses are treated as completed in backlog views and reporting." done_status: "Done status" - sharing_description: "This project can either share its own sprints, receive shared sprints or handle sprints independently (no sharing)." sharing: "Sharing" label_burndown_chart: "Burndown chart" label_sprint_board: "Sprint board" @@ -227,6 +226,13 @@ en: story_points: "Story points" story_points_ideal: "Story points (ideal)" + ee: + features: + sprint_sharing: "Sprint sharing" + upsell: + sprint_sharing: + description: "Share sprints across projects to align teams and coordinate work in scaled agile setups (SAFe)." + label_backlog: "Backlog" label_backlog_bucket_edit: "Edit backlog bucket" label_backlog_bucket_new: "New backlog bucket" @@ -278,4 +284,10 @@ en: info: "Sharing a sprint will share the name, status and the start and finish dates in all projects. These cannot be modified in projects that receive and use these sprints." sprint_sharing: Share sprints + backlogs: + sharing_form_component: + sharing_description: "This project can either share its own sprints, receive shared sprints or handle sprints independently (no sharing)." + sharing_fallback_description: "Lacking a corporate enterprise plan, the sharing options are limited to the project's own sprints. The currently active setting remains active." + + remaining_hours: "remaining work" diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 0129945736be..8338e9f9438f 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -136,6 +136,7 @@ def self.settings patch_with_namespace :WorkPackages, :SetAttributesService patch_with_namespace :WorkPackages, :BaseContract patch_with_namespace :WorkPackages, :UpdateContract + patch_with_namespace :Projects, :CopyService patch_with_namespace :API, :V3, :WorkPackages, :EagerLoading, :Checksum patch_with_namespace :API, :V3, :WorkPackages, :Schema, :SpecificWorkPackageSchema diff --git a/modules/backlogs/lib/open_project/backlogs/patches/copy_service_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/copy_service_patch.rb new file mode 100644 index 000000000000..34e8fb663d01 --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/patches/copy_service_patch.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module OpenProject::Backlogs::Patches::CopyServicePatch + extend ActiveSupport::Concern + + included do + prepend InstanceMethods + end + + module InstanceMethods + def clean_settings_attributes!(settings) + # There can be only one project sharing with all projects. + if settings["sprint_sharing"] == Projects::SprintSharing::SHARE_ALL_PROJECTS || + !EnterpriseToken.allows_to?(:sprint_sharing) + settings.delete("sprint_sharing") + end + + super + end + end +end diff --git a/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb b/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb index 510289290d18..f6cadae09287 100644 --- a/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb +++ b/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb @@ -3,11 +3,11 @@ require "spec_helper" require "contracts/shared/model_contract_shared_context" -RSpec.describe Projects::BacklogSettingsContract, type: :model do +RSpec.describe Projects::BacklogSettingsContract, type: :model, with_ee: %i[sprint_sharing] do include_context "ModelContract shared context" let(:current_user) { build_stubbed(:user) } - let(:project) { create(:project) } + let(:project) { build_stubbed(:project) } let(:permissions) { %i(share_sprint) } subject(:contract) { described_class.new(project, current_user) } @@ -35,7 +35,7 @@ # This spec of explicitly setting sprint_sharing to empty is required because the # simple presence validation spec is not sufficient to catch certain corner cases. - # For example, when the sprint_sharing getter is overriden to provide a default value, + # For example, when the sprint_sharing getter is overridden to provide a default value, # and the user submits an empty value, the contract should be invalid. context "when sprint_sharing is empty" do before { project.sprint_sharing = "" } @@ -57,6 +57,62 @@ end end + context "when the `sprint_sharing` is not part of the current EE token", with_ee: [] do + context "when sprint sharing is set to 'no_sharing'" do + before { project.sprint_sharing = Project::NO_SHARING } + + it_behaves_like "contract is valid" + end + + context "when sprint sharing is set to 'share_all_projects'" do + before { project.sprint_sharing = Project::SHARE_ALL_PROJECTS } + + it_behaves_like "contract is invalid", + sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" } + end + + context "when sprint sharing is set to 'share_subprojects'" do + before { project.sprint_sharing = Project::SHARE_SUBPROJECTS } + + it_behaves_like "contract is invalid", + sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" } + end + + context "when sprint sharing is set to 'receive_shared'" do + before { project.sprint_sharing = Project::RECEIVE_SHARED } + + it_behaves_like "contract is invalid", + sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" } + end + + context "when sprint sharing remains on 'share_all_projects'" do + before do + project.sprint_sharing = Project::SHARE_ALL_PROJECTS + project.clear_changes_information + end + + it_behaves_like "contract is valid" + end + + context "when sprint sharing remains on 'share_subprojects'" do + before do + project.sprint_sharing = Project::SHARE_SUBPROJECTS + project.clear_changes_information + end + + it_behaves_like "contract is valid" + end + + context "when sprint sharing remains on 'receive_shared'" do + before do + project.sprint_sharing = Project::RECEIVE_SHARED + project.clear_changes_information + end + + it_behaves_like "contract is valid" + end + end + describe "#validate_global_sprint_sharer_uniqueness" do before do project.sprint_sharing = "share_all_projects" diff --git a/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb b/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb index f498dd1422c5..60f208f43418 100644 --- a/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb +++ b/modules/backlogs/spec/features/projects/settings/backlog_sharing_settings_spec.rb @@ -42,7 +42,8 @@ login_as current_user end - context "with share_sprint permission" do + context "with share_sprint permission and enterprise token", + with_ee: [:sprint_sharing] do it "displays and stores sprint sharing settings" do visit project_settings_backlog_sharing_path(project) @@ -127,6 +128,50 @@ end end + context "with share_sprint permission but no enterprise token" do + context "without existing sharing setting in the project" do + it "shows an enterprise token teaser" do + visit project_settings_backlog_sharing_path(project) + + expect(page).to have_text("Share sprints across projects to align teams") + + expect(page).to have_no_field("Don't share") + expect(page).to have_no_field("All projects") + expect(page).to have_no_field("Subprojects") + expect(page).to have_no_field("Receive shared sprints") + end + end + + context "with existing sharing setting in the project" do + before do + project.update!(sprint_sharing: "receive_shared") + end + + it "shows the existing sharing setting but disables them except for `Don't share`" do + visit project_settings_backlog_sharing_path(project) + + # All radio buttons are present with the selected option displayed. + # But all except the "Don't share" option are disabled. + expect(page).to have_unchecked_field("Don't share") + expect(page).to have_unchecked_field("All projects", disabled: true) + expect(page).to have_unchecked_field("Subprojects", disabled: true) + expect(page).to have_checked_field("Receive shared sprints", disabled: true) + + choose("Don't share") + + click_button "Save" + + # Now that the `Don't share` option is selected, the large enterprise banner is displayed. + expect(page).to have_text("Share sprints across projects to align teams") + + expect(page).to have_no_field("Don't share") + expect(page).to have_no_field("All projects") + expect(page).to have_no_field("Subprojects") + expect(page).to have_no_field("Receive shared sprints") + end + end + end + context "without share_sprint permission" do let(:permissions) { %i[create_sprints select_done_statuses] } diff --git a/modules/backlogs/spec/services/projects/copy_service_integration_spec.rb b/modules/backlogs/spec/services/projects/copy_service_integration_spec.rb new file mode 100644 index 000000000000..05443b2e93c7 --- /dev/null +++ b/modules/backlogs/spec/services/projects/copy_service_integration_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +require "spec_helper" + +RSpec.describe Projects::CopyService, "integration", type: :model do + shared_let(:role) do + create(:project_role, + permissions: %i[copy_projects]) + end + shared_let(:source) do + create(:project, + name: "Source Project Name", + enabled_module_names: %i[work_package_tracking backlogs]) + end + shared_let(:current_user) do + create(:user, + member_with_roles: { source => role }) + end + + let(:instance) { described_class.new(source:, user: current_user) } + let(:target_project_params) do + { name: "Target Project Name", identifier: "some-identifier" } + end + let(:params) do + { target_project_params:, send_notifications: false } + end + let(:project_copy) { subject.result } + + describe ".call" do + subject { instance.call(params) } + + describe "#sprint_sharing setting" do + context "with an ee license for sprint sharing", with_ee: %i[sprint_sharing] do + context "when the source project is set to receive" do + before do + source.sprint_sharing = Projects::SprintSharing::RECEIVE_SHARED + source.save! + end + + it "copies the backlog sharing setting" do + expect(subject).to be_success + expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::RECEIVE_SHARED + end + end + + context "when the source project is set to share with subprojects" do + before do + source.sprint_sharing = Projects::SprintSharing::SHARE_SUBPROJECTS + source.save! + end + + it "copies the backlog sharing setting" do + expect(subject).to be_success + expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::SHARE_SUBPROJECTS + end + end + + context "when the source project is set to not share" do + before do + source.sprint_sharing = Projects::SprintSharing::NO_SHARING + source.save! + end + + it "copies the backlog sharing setting" do + expect(subject).to be_success + expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING + end + end + + context "when the source project is set to share with all" do + before do + source.sprint_sharing = Projects::SprintSharing::SHARE_ALL_PROJECTS + source.save! + end + + it "does not copy the setting as that would result in two projects sharing with all" do + expect(subject).to be_success + expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING + end + end + end + + context "without an ee license for sprint sharing", with_ee: %i[] do + Projects::SprintSharing::SPRINT_SHARING_MODES.each do |mode| + context "when the source project is set to #{mode}" do + before do + source.sprint_sharing = mode + source.save! + end + + it "copies the backlog sharing setting" do + expect(subject).to be_success + expect(project_copy.sprint_sharing).to eq Projects::SprintSharing::NO_SHARING + end + end + end + end + end + end +end diff --git a/spec/contracts/shared/model_contract_shared_context.rb b/spec/contracts/shared/model_contract_shared_context.rb index ab36a18e7bc3..94bf6284422d 100644 --- a/spec/contracts/shared/model_contract_shared_context.rb +++ b/spec/contracts/shared/model_contract_shared_context.rb @@ -48,7 +48,11 @@ def expect_contract_invalid(errors = {}) [error_symbols] end end - contract_errors = errors.keys.index_with { |key| contract.errors.symbols_for(key) } + + contract_errors = errors.keys.index_with do |key| + errors[key].is_a?(Hash) ? contract.errors.details[key] : contract.errors.symbols_for(key) + end + expect(contract_errors).to match(expected_errors) if RSpec.current_example.metadata[:check_errors_i18n] # ensure no I18n::MissingTranslationData is raised because of missing attributes and/or errors translations