diff --git a/app/contracts/custom_fields/hierarchy/generate_root_contract.rb b/app/contracts/custom_fields/hierarchy/generate_root_contract.rb new file mode 100644 index 000000000000..4e993e5f5b40 --- /dev/null +++ b/app/contracts/custom_fields/hierarchy/generate_root_contract.rb @@ -0,0 +1,43 @@ +# 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 CustomFields + module Hierarchy + class GenerateRootContract < Dry::Validation::Contract + params do + required(:hierarchy_root) + end + + rule(:hierarchy_root) do + key.failure("Hierarchical root already set") unless value.nil? + end + end + end +end diff --git a/app/contracts/custom_fields/hierarchy/insert_item_contract.rb b/app/contracts/custom_fields/hierarchy/insert_item_contract.rb new file mode 100644 index 000000000000..57d5e7714809 --- /dev/null +++ b/app/contracts/custom_fields/hierarchy/insert_item_contract.rb @@ -0,0 +1,57 @@ +# 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 CustomFields + module Hierarchy + class InsertItemContract < Dry::Validation::Contract + params do + required(:parent).filled + required(:label).filled(:string) + optional(:short).filled(:string) + end + + rule(:parent) do + if value.is_a?(CustomField::Hierarchy::Item) + unless value.persisted? + key.failure("Parent must exist") + end + else + key.failure("Parent must be of type 'Item'") + end + end + + rule(:label) do + if CustomField::Hierarchy::Item.exists?(parent_id: values[:parent], label: value) + key.failure("Label must be unique within the same hierarchy level") + end + end + end + end +end diff --git a/app/contracts/custom_fields/hierarchy/service_initialization_contract.rb b/app/contracts/custom_fields/hierarchy/service_initialization_contract.rb new file mode 100644 index 000000000000..8b72a534ba20 --- /dev/null +++ b/app/contracts/custom_fields/hierarchy/service_initialization_contract.rb @@ -0,0 +1,43 @@ +# 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 CustomFields + module Hierarchy + class ServiceInitializationContract < Dry::Validation::Contract + params do + required(:field_format).filled(:string) + end + + rule(:field_format) do + key.failure("Custom field must have field format 'hierarchy'") if value != "hierarchy" + end + end + end +end diff --git a/app/services/custom_fields/hierarchy/hierarchical_item_service.rb b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb new file mode 100644 index 000000000000..d64ae9e79279 --- /dev/null +++ b/app/services/custom_fields/hierarchy/hierarchical_item_service.rb @@ -0,0 +1,79 @@ +# 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 CustomFields + module Hierarchy + class HierarchicalItemService + include Dry::Monads[:result] + + def initialize(custom_field) + validation = ServiceInitializationContract.new.call(field_format: custom_field.field_format) + # rubocop:disable Rails/DeprecatedActiveModelErrorsMethods + raise ArgumentError, "Invalid custom field: #{validation.errors.to_h}" if validation.failure? + # rubocop:enable Rails/DeprecatedActiveModelErrorsMethods + + @custom_field = custom_field + end + + def generate_root + CustomFields::Hierarchy::GenerateRootContract + .new + .call(hierarchy_root: @custom_field.hierarchy_root) + .to_monad + .bind { create_root_item } + end + + def insert_item(parent:, label:, short: nil) + CustomFields::Hierarchy::InsertItemContract + .new + .call({ parent:, label:, short: }.compact) + .to_monad + .bind { |validation| create_child_item(validation) } + end + + private + + def create_root_item + item = CustomField::Hierarchy::Item.create(custom_field: @custom_field) + return Failure(item.errors) unless item.persisted? + + Success(item) + end + + def create_child_item(validation) + item = CustomField::Hierarchy::Item + .create(parent: validation[:parent], label: validation[:label], short: validation[:short]) + return Failure(item.errors) unless item.persisted? + + Success(item) + end + end + end +end diff --git a/spec/contracts/custom_fields/hierarchy/generate_root_contract_spec.rb b/spec/contracts/custom_fields/hierarchy/generate_root_contract_spec.rb new file mode 100644 index 000000000000..78429458a2d9 --- /dev/null +++ b/spec/contracts/custom_fields/hierarchy/generate_root_contract_spec.rb @@ -0,0 +1,78 @@ +# 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 "rails_helper" + +RSpec.describe CustomFields::Hierarchy::GenerateRootContract do + subject { described_class.new } + + describe "#call" do + context "when hierarchy_root is nil" do + let(:custom_field) { create(:custom_field, field_format: "hierarchy", hierarchy_root: nil) } + + it "is valid" do + result = subject.call(hierarchy_root: custom_field.hierarchy_root) + expect(result).to be_success + end + end + + context "when hierarchy_root is not nil" do + let(:hierarchy_root) { create(:hierarchy_item) } + let(:custom_field) { create(:custom_field, field_format: "hierarchy", hierarchy_root:) } + + it "is invalid" do + result = subject.call(hierarchy_root: custom_field.hierarchy_root) + expect(result).to be_failure + # rubocop:disable Rails/DeprecatedActiveModelErrorsMethods + expect(result.errors.to_h).to include(hierarchy_root: ["Hierarchical root already set"]) + # rubocop:enable Rails/DeprecatedActiveModelErrorsMethods + end + end + + context "when inputs are valid" do + it "creates a success result" do + [ + { hierarchy_root: nil } + ].each { |params| expect(subject.call(params)).to be_success } + end + end + + context "when inputs are invalid" do + it "creates a failure result" do + [ + {}, + { hierarchy_root: create(:hierarchy_item) }, + { hierarchy_root: "" }, + { hierarchy_root: 42 } + ].each { |params| expect(subject.call(params)).to be_failure } + end + end + end +end diff --git a/spec/contracts/custom_fields/hierarchy/insert_item_contract_spec.rb b/spec/contracts/custom_fields/hierarchy/insert_item_contract_spec.rb new file mode 100644 index 000000000000..70af1da8caae --- /dev/null +++ b/spec/contracts/custom_fields/hierarchy/insert_item_contract_spec.rb @@ -0,0 +1,119 @@ +# 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 "rails_helper" + +RSpec.describe CustomFields::Hierarchy::InsertItemContract do + subject { described_class.new } + + # rubocop:disable Rails/DeprecatedActiveModelErrorsMethods + describe "#call" do + let(:parent) { create(:hierarchy_item) } + + context "when all required fields are valid" do + let(:params) { { parent:, label: "Valid Label" } } + + it "is valid" do + result = subject.call(params) + expect(result).to be_success + end + end + + context "when parent is not of type 'Item'" do + let(:invalid_parent) { create(:custom_field) } + let(:params) { { parent: invalid_parent, label: "Valid Label" } } + + it "is invalid" do + result = subject.call(params) + expect(result).to be_failure + expect(result.errors.to_h).to include(parent: ["Parent must be of type 'Item'"]) + end + end + + context "when label is not unique within the same hierarchy level" do + before do + create(:hierarchy_item, parent:, label: "Duplicate Label") + end + + let(:params) { { parent:, label: "Duplicate Label" } } + + it "is invalid" do + result = subject.call(params) + expect(result).to be_failure + expect(result.errors.to_h).to include(label: ["Label must be unique within the same hierarchy level"]) + end + end + + context "when short is set and is a string" do + let(:params) { { parent:, label: "Valid Label", short: "Valid Short" } } + + it "is valid" do + result = subject.call(params) + expect(result).to be_success + end + end + + context "when short is set and is not a string" do + let(:params) { { parent:, label: "Valid Label", short: 123 } } + + it "is invalid" do + result = subject.call(params) + expect(result).to be_failure + expect(result.errors.to_h).to include(short: ["must be a string"]) + end + end + + context "when inputs are valid" do + it "creates a success result" do + [ + { parent:, label: "A label", short: "A shorthand" }, + { parent:, label: "A label" } + ].each { |params| expect(subject.call(params)).to be_success } + end + end + + context "when inputs are invalid" do + it "creates a failure result" do + [ + { parent:, label: "A label", short: "" }, + { parent:, label: "A label", short: nil }, + { parent:, label: "" }, + { parent:, label: nil }, + { parent: }, + { parent: nil }, + { parent: nil, label: "A label" }, + { parent: "parent", label: "A label" }, + { parent: 42, label: "A label" } + ].each { |params| expect(subject.call(params)).to be_failure } + end + end + end + # rubocop:enable Rails/DeprecatedActiveModelErrorsMethods +end diff --git a/spec/contracts/custom_fields/hierarchy/service_initialization_contract_spec.rb b/spec/contracts/custom_fields/hierarchy/service_initialization_contract_spec.rb new file mode 100644 index 000000000000..2cf1979cc78d --- /dev/null +++ b/spec/contracts/custom_fields/hierarchy/service_initialization_contract_spec.rb @@ -0,0 +1,97 @@ +# 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 "rails_helper" + +RSpec.describe CustomFields::Hierarchy::ServiceInitializationContract do + subject { described_class.new } + + # rubocop:disable Rails/DeprecatedActiveModelErrorsMethods + describe "#call" do + context "when field_format is 'hierarchy'" do + let(:params) { { field_format: "hierarchy" } } + + it "is valid" do + result = subject.call(params) + expect(result).to be_success + end + end + + context "when field_format is not 'hierarchy'" do + let(:params) { { field_format: "text" } } + + it "is invalid" do + result = subject.call(params) + expect(result).to be_failure + expect(result.errors.to_h).to include(field_format: ["Custom field must have field format 'hierarchy'"]) + end + end + + context "when field_format is missing" do + let(:params) { {} } + + it "is invalid" do + result = subject.call(params) + expect(result).to be_failure + expect(result.errors.to_h).to include(field_format: ["is missing"]) + end + end + + context "when field_format is nil" do + let(:params) { { field_format: nil } } + + it "is invalid" do + result = subject.call(params) + expect(result).to be_failure + expect(result.errors.to_h).to include(field_format: ["must be filled"]) + end + end + + context "when inputs are valid" do + it "creates a success result" do + [ + { field_format: "hierarchy" } + ].each { |params| expect(subject.call(params)).to be_success } + end + end + + context "when inputs are invalid" do + it "creates a failure result" do + [ + {}, + { field_format: "text" }, + { field_format: nil }, + { field_format: 42 } + ].each { |params| expect(subject.call(params)).to be_failure } + end + end + end + # rubocop:enable Rails/DeprecatedActiveModelErrorsMethods +end diff --git a/spec/factories/hierarchy_item_factory.rb b/spec/factories/hierarchy_item_factory.rb new file mode 100644 index 000000000000..5f8b1026d3c1 --- /dev/null +++ b/spec/factories/hierarchy_item_factory.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :hierarchy_item, class: "CustomField::Hierarchy::Item" do + sequence(:label) { |n| "Item #{n}" } + end +end diff --git a/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb b/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb new file mode 100644 index 000000000000..b5199522c2c5 --- /dev/null +++ b/spec/services/custom_fields/hierarchy/hierarchical_item_service_spec.rb @@ -0,0 +1,103 @@ +# 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 "rails_helper" + +RSpec.describe CustomFields::Hierarchy::HierarchicalItemService do + let(:custom_field) { create(:custom_field, field_format: "hierarchy", hierarchy_root: nil) } + let(:invalid_custom_field) { create(:custom_field, field_format: "text", hierarchy_root: nil) } + + describe "#initialize" do + context "with valid custom field" do + it "initializes successfully" do + expect { described_class.new(custom_field) }.not_to raise_error + end + end + + context "with invalid custom field" do + it "raises an ArgumentError" do + expect { described_class.new(invalid_custom_field) }.to raise_error(ArgumentError, /Invalid custom field/) + end + end + end + + describe "#generate_root" do + let(:service) { described_class.new(custom_field) } + + context "with valid hierarchy root" do + it "creates a root item successfully" do + expect(service.generate_root).to be_success + end + end + + context "with persistence of hierarchy root fails" do + it "fails to create a root item" do + allow(CustomField::Hierarchy::Item) + .to receive(:create) + .and_return(instance_double(CustomField::Hierarchy::Item, persisted?: false, errors: "some errors")) + + result = service.generate_root + expect(result).to be_failure + end + end + end + + describe "#insert_item" do + let(:custom_field) { create(:custom_field, field_format: "hierarchy", hierarchy_root: parent) } + let(:service) { described_class.new(custom_field) } + + let(:parent) { create(:hierarchy_item) } + let(:label) { "Child Item" } + let(:short) { "Short Description" } + + context "with valid parameters" do + it "inserts an item successfully without short" do + result = service.insert_item(parent:, label:) + expect(result).to be_success + end + + it "inserts an item successfully with short" do + result = service.insert_item(parent:, label:, short:) + expect(result).to be_success + end + end + + context "with invalid item" do + it "fails to insert an item" do + allow(CustomField::Hierarchy::Item) + .to receive(:create).and_return(instance_double(CustomField::Hierarchy::Item, + persisted?: false, errors: "some errors")) + + result = service.insert_item(parent:, label:, short:) + expect(result).to be_failure + end + end + end +end