From 3d7ed5c5cb46ee1757333b268e9de2cbd463b290 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 16 Sep 2026 19:31:41 +0300 Subject: [PATCH 1/4] Add contracts, services and a query for managing labels Admins will manage labels from a dedicated page. The usage count moves to a correlated subquery so will_paginate keeps a scalar total, and the name filter only advertises the operators it implements. --- app/contracts/labels/base_contract.rb | 37 +++++++ app/contracts/labels/create_contract.rb | 34 +++++++ app/contracts/labels/delete_contract.rb | 35 +++++++ app/contracts/labels/update_contract.rb | 34 +++++++ app/models/label.rb | 4 +- app/models/queries/labels.rb | 35 +++++++ .../queries/labels/filters/label_filter.rb | 33 +++++++ .../queries/labels/filters/name_filter.rb | 59 ++++++++++++ app/models/queries/labels/label_query.rb | 42 ++++++++ app/services/labels/create_service.rb | 34 +++++++ app/services/labels/delete_service.rb | 34 +++++++ app/services/labels/set_attributes_service.rb | 41 ++++++++ app/services/labels/update_service.rb | 34 +++++++ spec/contracts/labels/create_contract_spec.rb | 45 +++++++++ spec/contracts/labels/delete_contract_spec.rb | 45 +++++++++ spec/contracts/labels/update_contract_spec.rb | 45 +++++++++ spec/models/label_spec.rb | 8 ++ .../models/queries/labels/label_query_spec.rb | 96 +++++++++++++++++++ spec/services/labels/create_service_spec.rb | 68 +++++++++++++ spec/services/labels/delete_service_spec.rb | 59 ++++++++++++ spec/services/labels/update_service_spec.rb | 68 +++++++++++++ 21 files changed, 887 insertions(+), 3 deletions(-) create mode 100644 app/contracts/labels/base_contract.rb create mode 100644 app/contracts/labels/create_contract.rb create mode 100644 app/contracts/labels/delete_contract.rb create mode 100644 app/contracts/labels/update_contract.rb create mode 100644 app/models/queries/labels.rb create mode 100644 app/models/queries/labels/filters/label_filter.rb create mode 100644 app/models/queries/labels/filters/name_filter.rb create mode 100644 app/models/queries/labels/label_query.rb create mode 100644 app/services/labels/create_service.rb create mode 100644 app/services/labels/delete_service.rb create mode 100644 app/services/labels/set_attributes_service.rb create mode 100644 app/services/labels/update_service.rb create mode 100644 spec/contracts/labels/create_contract_spec.rb create mode 100644 spec/contracts/labels/delete_contract_spec.rb create mode 100644 spec/contracts/labels/update_contract_spec.rb create mode 100644 spec/models/queries/labels/label_query_spec.rb create mode 100644 spec/services/labels/create_service_spec.rb create mode 100644 spec/services/labels/delete_service_spec.rb create mode 100644 spec/services/labels/update_service_spec.rb diff --git a/app/contracts/labels/base_contract.rb b/app/contracts/labels/base_contract.rb new file mode 100644 index 000000000000..195a4ed20884 --- /dev/null +++ b/app/contracts/labels/base_contract.rb @@ -0,0 +1,37 @@ +# 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 Labels + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :name + end +end diff --git a/app/contracts/labels/create_contract.rb b/app/contracts/labels/create_contract.rb new file mode 100644 index 000000000000..dbbb41bf11b5 --- /dev/null +++ b/app/contracts/labels/create_contract.rb @@ -0,0 +1,34 @@ +# 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 Labels + class CreateContract < BaseContract + end +end diff --git a/app/contracts/labels/delete_contract.rb b/app/contracts/labels/delete_contract.rb new file mode 100644 index 000000000000..4b0eb5804c2d --- /dev/null +++ b/app/contracts/labels/delete_contract.rb @@ -0,0 +1,35 @@ +# 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 Labels + class DeleteContract < ::DeleteContract + delete_permission :admin + end +end diff --git a/app/contracts/labels/update_contract.rb b/app/contracts/labels/update_contract.rb new file mode 100644 index 000000000000..4a7b9702e339 --- /dev/null +++ b/app/contracts/labels/update_contract.rb @@ -0,0 +1,34 @@ +# 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 Labels + class UpdateContract < BaseContract + end +end diff --git a/app/models/label.rb b/app/models/label.rb index c6c2b8fb2809..960d7b8622ab 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -33,9 +33,7 @@ class Label < ApplicationRecord has_many :labelings, dependent: :delete_all scope :with_usage_count, -> { - left_joins(:labelings) - .select("labels.*, COUNT(labelings.id) AS usage_count") - .group(:id) + select("labels.*, (SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id) AS usage_count") } validates :name, diff --git a/app/models/queries/labels.rb b/app/models/queries/labels.rb new file mode 100644 index 000000000000..2ac693a3ea59 --- /dev/null +++ b/app/models/queries/labels.rb @@ -0,0 +1,35 @@ +# 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 Queries::Labels + ::Queries::Register.register(LabelQuery) do + filter Filters::NameFilter + end +end diff --git a/app/models/queries/labels/filters/label_filter.rb b/app/models/queries/labels/filters/label_filter.rb new file mode 100644 index 000000000000..7a10433f6d7c --- /dev/null +++ b/app/models/queries/labels/filters/label_filter.rb @@ -0,0 +1,33 @@ +# 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. +#++ + +class Queries::Labels::Filters::LabelFilter < Queries::Filters::Base + self.model = Label +end diff --git a/app/models/queries/labels/filters/name_filter.rb b/app/models/queries/labels/filters/name_filter.rb new file mode 100644 index 000000000000..12e1dfab4131 --- /dev/null +++ b/app/models/queries/labels/filters/name_filter.rb @@ -0,0 +1,59 @@ +# 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. +#++ + +class Queries::Labels::Filters::NameFilter < Queries::Labels::Filters::LabelFilter + def self.key + :name + end + + def type + :string + end + + def human_name + Label.human_attribute_name(name) + end + + def available_operators + [::Queries::Operators::Contains, ::Queries::Operators::Everywhere, ::Queries::Operators::NotContains] + end + + def where + escaped = ActiveRecord::Base.sanitize_sql_like(values.first) + case operator + when "~", "**" + ["labels.name ILIKE :q", { q: "%#{escaped}%" }] + when "!~" + ["labels.name NOT ILIKE :q", { q: "%#{escaped}%" }] + else + raise "Unsupported operator #{operator}" + end + end +end diff --git a/app/models/queries/labels/label_query.rb b/app/models/queries/labels/label_query.rb new file mode 100644 index 000000000000..44ec09a0a67f --- /dev/null +++ b/app/models/queries/labels/label_query.rb @@ -0,0 +1,42 @@ +# 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. +#++ + +class Queries::Labels::LabelQuery + include Queries::BaseQuery + include Queries::UnpersistedQuery + + def self.model + Label + end + + def default_scope + Label.with_usage_count.order(Label.arel_table[:name].lower.asc) + end +end diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb new file mode 100644 index 000000000000..61b4bee4b80d --- /dev/null +++ b/app/services/labels/create_service.rb @@ -0,0 +1,34 @@ +# 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 Labels + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/labels/delete_service.rb b/app/services/labels/delete_service.rb new file mode 100644 index 000000000000..3efeeb5600c9 --- /dev/null +++ b/app/services/labels/delete_service.rb @@ -0,0 +1,34 @@ +# 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 Labels + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/labels/set_attributes_service.rb b/app/services/labels/set_attributes_service.rb new file mode 100644 index 000000000000..82c923683c04 --- /dev/null +++ b/app/services/labels/set_attributes_service.rb @@ -0,0 +1,41 @@ +# 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 Labels + class SetAttributesService < ::BaseServices::SetAttributes + private + + def set_default_attributes(_params) + model.change_by_system do + model.author = user + end + end + end +end diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb new file mode 100644 index 000000000000..c1c55adede2a --- /dev/null +++ b/app/services/labels/update_service.rb @@ -0,0 +1,34 @@ +# 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 Labels + class UpdateService < ::BaseServices::Update + end +end diff --git a/spec/contracts/labels/create_contract_spec.rb b/spec/contracts/labels/create_contract_spec.rb new file mode 100644 index 000000000000..d2f339723184 --- /dev/null +++ b/spec/contracts/labels/create_contract_spec.rb @@ -0,0 +1,45 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Labels::CreateContract do + include_context "ModelContract shared context" + + let(:label) { build_stubbed(:label) } + let(:contract) { described_class.new(label, current_user) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + include_examples "contract reuses the model errors" do + let(:current_user) { build_stubbed(:admin) } + end +end diff --git a/spec/contracts/labels/delete_contract_spec.rb b/spec/contracts/labels/delete_contract_spec.rb new file mode 100644 index 000000000000..8c2a50b80f42 --- /dev/null +++ b/spec/contracts/labels/delete_contract_spec.rb @@ -0,0 +1,45 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Labels::DeleteContract do + include_context "ModelContract shared context" + + let(:label) { create(:label) } + let(:contract) { described_class.new(label, current_user) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + include_examples "contract reuses the model errors" do + let(:current_user) { build_stubbed(:admin) } + end +end diff --git a/spec/contracts/labels/update_contract_spec.rb b/spec/contracts/labels/update_contract_spec.rb new file mode 100644 index 000000000000..26f3bedf7bf1 --- /dev/null +++ b/spec/contracts/labels/update_contract_spec.rb @@ -0,0 +1,45 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Labels::UpdateContract do + include_context "ModelContract shared context" + + let(:label) { build_stubbed(:label) } + let(:contract) { described_class.new(label, current_user) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + include_examples "contract reuses the model errors" do + let(:current_user) { build_stubbed(:admin) } + end +end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index a9fbb5351fbf..8993d8839f78 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -58,6 +58,14 @@ expect(counts).to eq(used.id => 2, unused.id => 0) end + + it "keeps a scalar total when paginated" do + create_list(:label, 2) + create(:labeling, label: described_class.first) + + expect(described_class.with_usage_count.paginate(page: 1, per_page: 1).total_entries) + .to eq(described_class.count) + end end describe "#destroy" do diff --git a/spec/models/queries/labels/label_query_spec.rb b/spec/models/queries/labels/label_query_spec.rb new file mode 100644 index 000000000000..0546f5ece7dd --- /dev/null +++ b/spec/models/queries/labels/label_query_spec.rb @@ -0,0 +1,96 @@ +# 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 Queries::Labels::LabelQuery do + shared_let(:admin) { create(:admin) } + + shared_let(:banana) { create(:label, name: "banana") } + shared_let(:apple) { create(:label, name: "Apple") } + shared_let(:cherry) { create(:label, name: "cherry") } + + let(:instance) { described_class.new(user: admin) } + + describe "#results" do + it "orders labels case-insensitively by name" do + expect(instance.results.to_a).to eq([apple, banana, cherry]) + end + + it "exposes the usage count of each label" do + create(:labeling, label: apple) + + counts = instance.results.index_by(&:id).transform_values(&:usage_count) + + expect(counts).to eq(apple.id => 1, banana.id => 0, cherry.id => 0) + end + + it "paginates with a correct scalar total" do + expect(instance.results.paginate(page: 1, per_page: 1).total_entries).to eq(3) + end + end + + describe "name filter" do + it "narrows results case-insensitively with the ~ operator" do + instance.where("name", "~", ["app"]) + + expect(instance.results.to_a).to eq([apple]) + end + + it "excludes matches with the !~ operator" do + instance.where("name", "!~", ["app"]) + + expect(instance.results.to_a).to eq([banana, cherry]) + end + end + + describe "via ParamsToQueryService" do + it "applies the name filter parsed from JSON params" do + params = ActionController::Parameters.new( + filters: [{ name: { operator: "~", values: ["app"] } }].to_json + ) + + query = ParamsToQueryService.new(Label, admin, query_class: described_class).call(params) + + expect(query.results.to_a).to eq([apple]) + end + + it "rejects the = operator without raising on results" do + params = ActionController::Parameters.new( + filters: [{ name: { operator: "=", values: ["x"] } }].to_json + ) + + query = ParamsToQueryService.new(Label, admin, query_class: described_class).call(params) + + expect(query.valid?).to be false + expect { query.results.to_a }.not_to raise_error + end + end +end diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb new file mode 100644 index 000000000000..7539710711b7 --- /dev/null +++ b/spec/services/labels/create_service_spec.rb @@ -0,0 +1,68 @@ +# 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 Labels::CreateService, type: :model do + shared_let(:admin) { create(:admin) } + + let(:instance) { described_class.new(user: admin) } + + it "creates the label, setting the author to the calling user" do + result = instance.call(name: "Bug") + + expect(result).to be_success + + label = result.result + expect(label).to be_persisted + expect(label.author).to eq(admin) + expect(label.name).to eq("Bug") + end + + it "fails when the name is already taken, case-insensitively" do + create(:label, name: "Bug") + + result = instance.call(name: "BUG") + + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + end + + context "with a non-admin user" do + let(:instance) { described_class.new(user: create(:user)) } + + it "is unauthorized" do + result = instance.call(name: "Bug") + + expect(result).to be_failure + expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) + end + end +end diff --git a/spec/services/labels/delete_service_spec.rb b/spec/services/labels/delete_service_spec.rb new file mode 100644 index 000000000000..49c84b88c7a2 --- /dev/null +++ b/spec/services/labels/delete_service_spec.rb @@ -0,0 +1,59 @@ +# 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 Labels::DeleteService, type: :model do + shared_let(:admin) { create(:admin) } + let(:label) { create(:label) } + let(:instance) { described_class.new(user: admin, model: label) } + + it "removes the label and its labelings" do + labeling = create(:labeling, label:) + + result = instance.call + + expect(result).to be_success + expect(Label.where(id: label.id)).not_to exist + expect(Labeling.where(id: labeling.id)).not_to exist + end + + context "with a non-admin user" do + let(:instance) { described_class.new(user: create(:user), model: label) } + + it "is unauthorized and leaves the label intact" do + result = instance.call + + expect(result).to be_failure + expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) + expect(Label.where(id: label.id)).to exist + end + end +end diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb new file mode 100644 index 000000000000..8e1608290332 --- /dev/null +++ b/spec/services/labels/update_service_spec.rb @@ -0,0 +1,68 @@ +# 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 Labels::UpdateService, type: :model do + shared_let(:admin) { create(:admin) } + let(:label) { create(:label, name: "Bug") } + let(:instance) { described_class.new(user: admin, model: label) } + + it "renames the label" do + result = instance.call(name: "Defect") + + expect(result).to be_success + expect(label.reload.name).to eq("Defect") + end + + context "when the new name is already taken in a different case" do + before { create(:label, name: "defect") } + + it "fails with a taken error and does not persist the change" do + result = instance.call(name: "Defect") + + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + expect(label.reload.name).to eq("Bug") + end + end + + context "with a non-admin user" do + let(:instance) { described_class.new(user: create(:user), model: label) } + + it "is unauthorized" do + result = instance.call(name: "Defect") + + expect(result).to be_failure + expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) + expect(label.reload.name).to eq("Bug") + end + end +end From 6fd66500074692ceb7efbd39ea854f35f950f172 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 08:47:00 +0300 Subject: [PATCH 2/4] Normalise label names on assignment Surrounding whitespace is stripped and inner runs collapsed so that " Machine Learning " and "Machine Learning" are the same label, matching how project names are treated. --- app/models/label.rb | 2 ++ spec/models/label_spec.rb | 14 ++++++++++++++ spec/services/labels/create_service_spec.rb | 9 +++++++++ 3 files changed, 25 insertions(+) diff --git a/app/models/label.rb b/app/models/label.rb index 960d7b8622ab..763f5b5d9d29 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -36,6 +36,8 @@ class Label < ApplicationRecord select("labels.*, (SELECT COUNT(*) FROM labelings WHERE labelings.label_id = labels.id) AS usage_count") } + normalizes :name, with: -> { it.squish } + validates :name, presence: true, uniqueness: { case_sensitive: false }, diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 8993d8839f78..0c50cf8674fe 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -48,6 +48,20 @@ end end + describe "name normalization" do + it "strips surrounding whitespace and collapses repeated internal whitespace" do + label = build(:label, name: " Machine Learning ") + + expect(label.name).to eq("Machine Learning") + end + + it "normalizes the name used in a query" do + create(:label, name: "Machine Learning") + + expect(described_class.find_by(name: " Machine Learning ")).to be_present + end + end + describe ".with_usage_count" do it "counts the labelings of each label, including unused ones" do used = create(:label) diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb index 7539710711b7..9155cc0da66a 100644 --- a/spec/services/labels/create_service_spec.rb +++ b/spec/services/labels/create_service_spec.rb @@ -55,6 +55,15 @@ expect(result.errors.symbols_for(:name)).to include(:taken) end + it "fails when the name is already taken but for surrounding whitespace" do + create(:label, name: "Machine Learning") + + result = instance.call(name: " Machine Learning ") + + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + end + context "with a non-admin user" do let(:instance) { described_class.new(user: create(:user)) } From b926a8d437fd116f3855d270885275df66caf7a8 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 09:16:22 +0300 Subject: [PATCH 3/4] Let work package editors create labels The epic grants label creation to anyone who may edit work packages, while renaming and deleting stay with global admins. Locked users are rejected on every operation. --- app/contracts/labels/base_contract.rb | 2 - app/contracts/labels/create_contract.rb | 9 +++ app/contracts/labels/update_contract.rb | 1 + spec/contracts/labels/create_contract_spec.rb | 27 +++++++- .../labels/shared_contract_examples.rb | 61 +++++++++++++++++++ spec/contracts/labels/update_contract_spec.rb | 13 +++- 6 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 spec/contracts/labels/shared_contract_examples.rb diff --git a/app/contracts/labels/base_contract.rb b/app/contracts/labels/base_contract.rb index 195a4ed20884..df72474fd304 100644 --- a/app/contracts/labels/base_contract.rb +++ b/app/contracts/labels/base_contract.rb @@ -30,8 +30,6 @@ module Labels class BaseContract < ::ModelContract - include RequiresAdminGuard - attribute :name end end diff --git a/app/contracts/labels/create_contract.rb b/app/contracts/labels/create_contract.rb index dbbb41bf11b5..138068ae86db 100644 --- a/app/contracts/labels/create_contract.rb +++ b/app/contracts/labels/create_contract.rb @@ -30,5 +30,14 @@ module Labels class CreateContract < BaseContract + validate :user_allowed_to_create + + private + + def user_allowed_to_create + unless user.active? && (user.admin? || user.allowed_in_any_project?(:edit_work_packages)) + errors.add :base, :error_unauthorized + end + end end end diff --git a/app/contracts/labels/update_contract.rb b/app/contracts/labels/update_contract.rb index 4a7b9702e339..d2a92df2eb77 100644 --- a/app/contracts/labels/update_contract.rb +++ b/app/contracts/labels/update_contract.rb @@ -30,5 +30,6 @@ module Labels class UpdateContract < BaseContract + include RequiresAdminGuard end end diff --git a/spec/contracts/labels/create_contract_spec.rb b/spec/contracts/labels/create_contract_spec.rb index d2f339723184..ec9e2e9160b0 100644 --- a/spec/contracts/labels/create_contract_spec.rb +++ b/spec/contracts/labels/create_contract_spec.rb @@ -30,16 +30,37 @@ require "spec_helper" require "contracts/shared/model_contract_shared_context" +require_relative "shared_contract_examples" RSpec.describe Labels::CreateContract do include_context "ModelContract shared context" - let(:label) { build_stubbed(:label) } + shared_let(:project) { create(:project) } + + let(:label) { build(:label) } let(:contract) { described_class.new(label, current_user) } - it_behaves_like "contract is valid for active admins and invalid for regular users" + it_behaves_like "label contract" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[edit_work_packages] }) + end + end + + context "when admin" do + let(:current_user) { create(:admin) } + + it_behaves_like "contract is valid" + end + + context "when member without edit_work_packages permission" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }) + end + + it_behaves_like "contract user is unauthorized" + end include_examples "contract reuses the model errors" do - let(:current_user) { build_stubbed(:admin) } + let(:current_user) { create(:admin) } end end diff --git a/spec/contracts/labels/shared_contract_examples.rb b/spec/contracts/labels/shared_contract_examples.rb new file mode 100644 index 000000000000..06ef404e3d14 --- /dev/null +++ b/spec/contracts/labels/shared_contract_examples.rb @@ -0,0 +1,61 @@ +# 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.shared_examples_for "label contract" do + let(:locked_user) { create(:admin, status: User.statuses[:locked]) } + + it_behaves_like "contract is valid" + + context "when name is blank" do + before { label.name = "" } + + it_behaves_like "contract is invalid", name: :blank + end + + context "when name is too long" do + before { label.name = "a" * 256 } + + it_behaves_like "contract is invalid", name: :too_long + end + + context "when name is already taken" do + before { create(:label, name: label.name.upcase) } + + it_behaves_like "contract is invalid", name: :taken + end + + context "when the acting user is locked" do + let(:contract) { described_class.new(label, locked_user) } + + it_behaves_like "contract user is unauthorized" + end +end diff --git a/spec/contracts/labels/update_contract_spec.rb b/spec/contracts/labels/update_contract_spec.rb index 26f3bedf7bf1..2ae1f9e73856 100644 --- a/spec/contracts/labels/update_contract_spec.rb +++ b/spec/contracts/labels/update_contract_spec.rb @@ -30,6 +30,7 @@ require "spec_helper" require "contracts/shared/model_contract_shared_context" +require_relative "shared_contract_examples" RSpec.describe Labels::UpdateContract do include_context "ModelContract shared context" @@ -37,9 +38,17 @@ let(:label) { build_stubbed(:label) } let(:contract) { described_class.new(label, current_user) } - it_behaves_like "contract is valid for active admins and invalid for regular users" + it_behaves_like "label contract" do + let(:current_user) { create(:admin) } + end + + context "when non-admin" do + let(:current_user) { create(:user) } + + it_behaves_like "contract user is unauthorized" + end include_examples "contract reuses the model errors" do - let(:current_user) { build_stubbed(:admin) } + let(:current_user) { create(:admin) } end end From eb573d38a07b9868a0650916a917732fd02cbe6f Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Sep 2026 09:16:25 +0300 Subject: [PATCH 4/4] Build the label specs on the shared service, contract and filter examples --- spec/models/label_spec.rb | 14 +--- .../labels/filters/name_filter_spec.rb | 83 +++++++++++++++++++ .../models/queries/labels/label_query_spec.rb | 14 ---- spec/services/labels/create_service_spec.rb | 72 +++++++++------- spec/services/labels/delete_service_spec.rb | 40 +++++---- spec/services/labels/update_service_spec.rb | 54 +++++++----- 6 files changed, 184 insertions(+), 93 deletions(-) create mode 100644 spec/models/queries/labels/filters/name_filter_spec.rb diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 0c50cf8674fe..382c4e6a38ed 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -48,18 +48,10 @@ end end - describe "name normalization" do - it "strips surrounding whitespace and collapses repeated internal whitespace" do - label = build(:label, name: " Machine Learning ") - - expect(label.name).to eq("Machine Learning") - end - - it "normalizes the name used in a query" do - create(:label, name: "Machine Learning") + describe "normalizations" do + subject { build(:label) } - expect(described_class.find_by(name: " Machine Learning ")).to be_present - end + it { is_expected.to normalize(:name).from(" Machine Learning ").to("Machine Learning") } end describe ".with_usage_count" do diff --git a/spec/models/queries/labels/filters/name_filter_spec.rb b/spec/models/queries/labels/filters/name_filter_spec.rb new file mode 100644 index 000000000000..136f7c967974 --- /dev/null +++ b/spec/models/queries/labels/filters/name_filter_spec.rb @@ -0,0 +1,83 @@ +# 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 Queries::Labels::Filters::NameFilter do + include_context "filter tests" + let(:values) { ["app"] } + let(:model) { Label } + + it_behaves_like "basic query filter" do + let(:class_key) { :name } + let(:human_name) { Label.human_attribute_name(:name) } + let(:type) { :string } + let(:model) { Label } + + describe "#available_operators" do + it "supports ~, ** and !~" do + expect(instance.available_operators) + .to eql [Queries::Operators::Contains, Queries::Operators::Everywhere, Queries::Operators::NotContains] + end + end + end + + describe "#apply_to" do + context 'for "~"' do + let(:operator) { "~" } + + it "is the same as handwriting the query" do + expected = model.where("labels.name ILIKE '%app%'") + + expect(instance.apply_to(model).to_sql).to eql expected.to_sql + end + end + + context 'for "**"' do + let(:operator) { "**" } + + it "is the same as handwriting the query" do + expected = model.where("labels.name ILIKE '%app%'") + + expect(instance.apply_to(model).to_sql).to eql expected.to_sql + end + end + + context 'for "!~"' do + let(:operator) { "!~" } + + it "is the same as handwriting the query" do + expected = model.where("labels.name NOT ILIKE '%app%'") + + expect(instance.apply_to(model).to_sql).to eql expected.to_sql + end + end + end +end diff --git a/spec/models/queries/labels/label_query_spec.rb b/spec/models/queries/labels/label_query_spec.rb index 0546f5ece7dd..07b4e334543b 100644 --- a/spec/models/queries/labels/label_query_spec.rb +++ b/spec/models/queries/labels/label_query_spec.rb @@ -57,20 +57,6 @@ end end - describe "name filter" do - it "narrows results case-insensitively with the ~ operator" do - instance.where("name", "~", ["app"]) - - expect(instance.results.to_a).to eq([apple]) - end - - it "excludes matches with the !~ operator" do - instance.where("name", "!~", ["app"]) - - expect(instance.results.to_a).to eq([banana, cherry]) - end - end - describe "via ParamsToQueryService" do it "applies the name filter parsed from JSON params" do params = ActionController::Parameters.new( diff --git a/spec/services/labels/create_service_spec.rb b/spec/services/labels/create_service_spec.rb index 9155cc0da66a..4de08743e4fd 100644 --- a/spec/services/labels/create_service_spec.rb +++ b/spec/services/labels/create_service_spec.rb @@ -29,49 +29,65 @@ #++ require "spec_helper" +require "services/base_services/behaves_like_create_service" RSpec.describe Labels::CreateService, type: :model do - shared_let(:admin) { create(:admin) } + it_behaves_like "BaseServices create service" do + let(:call_attributes) { { name: "Some name" } } - let(:instance) { described_class.new(user: admin) } + context "with a real service call" do + let(:stub_model_instance) { false } + let(:user) { create(:admin) } + let(:call_attributes) { { name: "Bug" } } - it "creates the label, setting the author to the calling user" do - result = instance.call(name: "Bug") + it "creates the label, setting the author to the calling user" do + expect(subject).to be_success - expect(result).to be_success + label = subject.result + expect(label).to be_persisted + expect(label.author).to eq(user) + expect(label.name).to eq("Bug") + end - label = result.result - expect(label).to be_persisted - expect(label.author).to eq(admin) - expect(label.name).to eq("Bug") - end + it "fails when the name is already taken, case-insensitively" do + create(:label, name: "Bug") - it "fails when the name is already taken, case-insensitively" do - create(:label, name: "Bug") + result = described_class.new(user:).call(name: "BUG") - result = instance.call(name: "BUG") + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + end - expect(result).to be_failure - expect(result.errors.symbols_for(:name)).to include(:taken) - end + it "fails when the name is already taken but for surrounding whitespace" do + create(:label, name: "Machine Learning") - it "fails when the name is already taken but for surrounding whitespace" do - create(:label, name: "Machine Learning") + result = described_class.new(user:).call(name: " Machine Learning ") - result = instance.call(name: " Machine Learning ") + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + end - expect(result).to be_failure - expect(result.errors.symbols_for(:name)).to include(:taken) - end + context "with a user lacking edit_work_packages in any project" do + let(:user) { create(:user) } + + it "is unauthorized" do + expect(subject).to be_failure + expect(subject.errors.symbols_for(:base)).to include(:error_unauthorized) + end + end - context "with a non-admin user" do - let(:instance) { described_class.new(user: create(:user)) } + context "with a member holding edit_work_packages in a project" do + let(:project) { create(:project) } + let(:user) { create(:user, member_with_permissions: { project => %i[edit_work_packages] }) } - it "is unauthorized" do - result = instance.call(name: "Bug") + it "creates the label, setting the author to the calling user" do + expect(subject).to be_success - expect(result).to be_failure - expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) + label = subject.result + expect(label).to be_persisted + expect(label.author).to eq(user) + end + end end end end diff --git a/spec/services/labels/delete_service_spec.rb b/spec/services/labels/delete_service_spec.rb index 49c84b88c7a2..646ad66e369e 100644 --- a/spec/services/labels/delete_service_spec.rb +++ b/spec/services/labels/delete_service_spec.rb @@ -29,31 +29,37 @@ #++ require "spec_helper" +require "services/base_services/behaves_like_delete_service" RSpec.describe Labels::DeleteService, type: :model do - shared_let(:admin) { create(:admin) } - let(:label) { create(:label) } - let(:instance) { described_class.new(user: admin, model: label) } + it_behaves_like "BaseServices delete service" - it "removes the label and its labelings" do - labeling = create(:labeling, label:) + describe "with a real service call" do + shared_let(:admin) { create(:admin) } - result = instance.call + let(:label) { create(:label) } + let(:instance) { described_class.new(user: admin, model: label) } - expect(result).to be_success - expect(Label.where(id: label.id)).not_to exist - expect(Labeling.where(id: labeling.id)).not_to exist - end - - context "with a non-admin user" do - let(:instance) { described_class.new(user: create(:user), model: label) } + it "removes the label and its labelings" do + labeling = create(:labeling, label:) - it "is unauthorized and leaves the label intact" do result = instance.call - expect(result).to be_failure - expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) - expect(Label.where(id: label.id)).to exist + expect(result).to be_success + expect(Label.where(id: label.id)).not_to exist + expect(Labeling.where(id: labeling.id)).not_to exist + end + + context "with a non-admin user" do + let(:instance) { described_class.new(user: create(:user), model: label) } + + it "is unauthorized and leaves the label intact" do + result = instance.call + + expect(result).to be_failure + expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) + expect(Label.where(id: label.id)).to exist + end end end end diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb index 8e1608290332..7d1836f7ce39 100644 --- a/spec/services/labels/update_service_spec.rb +++ b/spec/services/labels/update_service_spec.rb @@ -29,40 +29,48 @@ #++ require "spec_helper" +require "services/base_services/behaves_like_update_service" RSpec.describe Labels::UpdateService, type: :model do - shared_let(:admin) { create(:admin) } - let(:label) { create(:label, name: "Bug") } - let(:instance) { described_class.new(user: admin, model: label) } - - it "renames the label" do - result = instance.call(name: "Defect") - - expect(result).to be_success - expect(label.reload.name).to eq("Defect") + it_behaves_like "BaseServices update service" do + let(:call_attributes) { { name: "Some name" } } end - context "when the new name is already taken in a different case" do - before { create(:label, name: "defect") } + describe "with a real service call" do + shared_let(:admin) { create(:admin) } + + let(:label) { create(:label, name: "Bug") } + let(:instance) { described_class.new(user: admin, model: label) } - it "fails with a taken error and does not persist the change" do + it "renames the label" do result = instance.call(name: "Defect") - expect(result).to be_failure - expect(result.errors.symbols_for(:name)).to include(:taken) - expect(label.reload.name).to eq("Bug") + expect(result).to be_success + expect(label.reload.name).to eq("Defect") end - end - context "with a non-admin user" do - let(:instance) { described_class.new(user: create(:user), model: label) } + context "when the new name is already taken in a different case" do + before { create(:label, name: "defect") } - it "is unauthorized" do - result = instance.call(name: "Defect") + it "fails with a taken error and does not persist the change" do + result = instance.call(name: "Defect") + + expect(result).to be_failure + expect(result.errors.symbols_for(:name)).to include(:taken) + expect(label.reload.name).to eq("Bug") + end + end + + context "with a non-admin user" do + let(:instance) { described_class.new(user: create(:user), model: label) } + + it "is unauthorized" do + result = instance.call(name: "Defect") - expect(result).to be_failure - expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) - expect(label.reload.name).to eq("Bug") + expect(result).to be_failure + expect(result.errors.symbols_for(:base)).to include(:error_unauthorized) + expect(label.reload.name).to eq("Bug") + end end end end