mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
5e3dd6a1d1
Documents created with zero-width Unicode characters (e.g. U+200B) in their titles become unclickable on the index page, making them hard to manage or delete. Introduce RemoveInvisibleCharacters normalizer, replacing the former RemoveAsciiControlCharacters. It strips both ASCII control characters and Unicode zero-width characters, with each category defined as a named constant for clarity. Apply it to Document#title and update existing callers (Project#identifier, CustomField#name). Add a shared RSpec example "strips invisible characters" to verify normalization consistently across all three models.
738 lines
20 KiB
Ruby
738 lines
20 KiB
Ruby
# 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 CustomField do
|
|
before do
|
|
described_class.destroy_all
|
|
end
|
|
|
|
let(:field) { build(:custom_field) }
|
|
let(:field2) { build(:custom_field) }
|
|
|
|
it { is_expected.to have_readonly_attribute(:field_format) }
|
|
|
|
describe "#name" do
|
|
it { is_expected.to validate_presence_of(:name) }
|
|
it { is_expected.to validate_length_of(:name).is_at_most(256) }
|
|
|
|
it_behaves_like "strips invisible characters", :name
|
|
|
|
describe "uniqueness" do
|
|
describe "WHEN value, locale and type are identical" do
|
|
before do
|
|
field.name = field2.name = "taken name"
|
|
field2.save!
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WHEN value and locale are identical and type is different" do
|
|
before do
|
|
field.name = field2.name = "taken name"
|
|
field2.save!
|
|
field.type = "TestCustomField"
|
|
end
|
|
|
|
it { expect(field).to be_valid }
|
|
end
|
|
|
|
describe "WHEN type and locale are identical and value is different" do
|
|
before do
|
|
field.name = "new name"
|
|
field2.name = "taken name"
|
|
field2.save!
|
|
end
|
|
|
|
it { expect(field).to be_valid }
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#valid?" do
|
|
describe "WITH a text field WITH minimum length blank" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.min_length = nil
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH maximum length blank" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.max_length = nil
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH minimum length not an integer" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.min_length = "a"
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH maximum length not an integer" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.max_length = "a"
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH minimum length greater than maximum length" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.min_length = 2
|
|
field.max_length = 1
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH negative minimum length" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.min_length = -2
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH negative maximum length" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.max_length = -2
|
|
end
|
|
|
|
it { expect(field).not_to be_valid }
|
|
end
|
|
|
|
describe "WITH a text field WITH an invalid regexp" do
|
|
before do
|
|
field.field_format = "text"
|
|
field.regexp = "[0-9}"
|
|
end
|
|
|
|
it "is not valid" do
|
|
expect(field).not_to be_valid
|
|
expect(field.errors[:regexp].size).to eq(1)
|
|
end
|
|
end
|
|
|
|
describe "WITH a list field WITH a custom option" do
|
|
before do
|
|
field.field_format = "list"
|
|
field.custom_options.build(value: "some value")
|
|
end
|
|
|
|
it "is valid" do
|
|
expect(field)
|
|
.to be_valid
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#all_attribute_names" do
|
|
subject { field.all_attribute_names }
|
|
|
|
context "when field has comments" do
|
|
let(:field) { build_stubbed(:custom_field, :has_comment) }
|
|
|
|
it { is_expected.to eq(["custom_field_#{field.id}", "custom_comment_#{field.id}"]) }
|
|
end
|
|
|
|
context "when field has no comments" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
it { is_expected.to eq(["custom_field_#{field.id}"]) }
|
|
end
|
|
end
|
|
|
|
describe "#attribute_name" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.attribute_name }
|
|
|
|
it { is_expected.to eq("custom_field_#{field.id}") }
|
|
|
|
context "when a format is provided" do
|
|
subject { field.attribute_name(:camel_case) }
|
|
|
|
it { is_expected.to eq("customField#{field.id}") }
|
|
end
|
|
end
|
|
|
|
describe "#comment_attribute_name" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.comment_attribute_name }
|
|
|
|
it { is_expected.to eq("custom_comment_#{field.id}") }
|
|
|
|
context "when a format is provided" do
|
|
subject { field.comment_attribute_name(:camel_case) }
|
|
|
|
it { is_expected.to eq("customComment#{field.id}") }
|
|
end
|
|
end
|
|
|
|
describe "#attribute_getter" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.attribute_getter }
|
|
|
|
it { is_expected.to eq(:"custom_field_#{field.id}") }
|
|
end
|
|
|
|
describe "#comment_attribute_getter" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.comment_attribute_getter }
|
|
|
|
it { is_expected.to eq(:"custom_comment_#{field.id}") }
|
|
end
|
|
|
|
describe "#attribute_setter" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.attribute_setter }
|
|
|
|
it { is_expected.to eq(:"custom_field_#{field.id}=") }
|
|
end
|
|
|
|
describe "#comment_attribute_setter" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.comment_attribute_setter }
|
|
|
|
it { is_expected.to eq(:"custom_comment_#{field.id}=") }
|
|
end
|
|
|
|
describe "#column_name" do
|
|
let(:field) { build_stubbed(:custom_field) }
|
|
|
|
subject { field.column_name }
|
|
|
|
it { is_expected.to eq("cf_#{field.id}") }
|
|
end
|
|
|
|
describe "#possible_values_options" do
|
|
let(:project) { build_stubbed(:project) }
|
|
let(:user1) { build_stubbed(:user) }
|
|
let(:user2) { build_stubbed(:user) }
|
|
let(:in_visible_scope) { instance_double(ActiveRecord::Relation) }
|
|
let(:principals_scope) { instance_double(ActiveRecord::Relation) }
|
|
let(:all_visible_scope) { instance_double(ActiveRecord::Relation) }
|
|
|
|
context "for a user custom field" do
|
|
before do
|
|
field.field_format = "user"
|
|
allow(project)
|
|
.to receive(:principals)
|
|
.and_return(principals_scope)
|
|
|
|
allow(principals_scope)
|
|
.to receive(:select)
|
|
.and_return([user1, user2])
|
|
|
|
allow(Principal).to receive_messages(
|
|
in_visible_project_or_me: in_visible_scope,
|
|
visible: all_visible_scope
|
|
)
|
|
|
|
allow(in_visible_scope).to receive(:select).and_return([user2])
|
|
allow(all_visible_scope).to receive(:select).and_return([user1])
|
|
end
|
|
|
|
context "for a project" do
|
|
it "is a list of name, id pairs" do
|
|
expect(field.possible_values_options(project))
|
|
.to contain_exactly([user1.name, user1.id.to_s], [user2.name, user2.id.to_s])
|
|
end
|
|
end
|
|
|
|
context "for something that responds to project" do
|
|
it "is a list of name, id pairs" do
|
|
object = OpenStruct.new(project:) # rubocop:disable Style/OpenStructUse
|
|
|
|
expect(field.possible_values_options(object))
|
|
.to contain_exactly([user1.name, user1.id.to_s], [user2.name, user2.id.to_s])
|
|
end
|
|
end
|
|
|
|
context "for nil" do
|
|
it "returns all principles visible to me" do
|
|
expect(field.possible_values_options)
|
|
.to contain_exactly([user2.name, user2.id.to_s])
|
|
end
|
|
end
|
|
|
|
context "with user format setting excluding lastname", with_settings: { user_format: :username } do
|
|
it "always includes lastname for Group#name{:lastname} aliasing" do
|
|
expect(field.possible_values_options)
|
|
.to contain_exactly([user2.name, user2.id.to_s])
|
|
|
|
expect(in_visible_scope).to have_received(:select)
|
|
.with("login", "lastname", "id", "type")
|
|
end
|
|
end
|
|
|
|
context "for a custom field bound to role assigment" do
|
|
let(:project_role) { build_stubbed(:project_role) }
|
|
let(:field) { build(:project_custom_field, :user, role_id: project_role.id) }
|
|
|
|
it "allows all visible users" do
|
|
expect(field.possible_values_options).to contain_exactly([user1.name, user1.id.to_s])
|
|
end
|
|
end
|
|
end
|
|
|
|
context "for a list custom field" do
|
|
let(:option1) { build_stubbed(:custom_option) }
|
|
let(:option2) { build_stubbed(:custom_option) }
|
|
|
|
before do
|
|
field.field_format = "list"
|
|
|
|
field.custom_options = [option1, option2]
|
|
end
|
|
|
|
it "is a list of name, id pairs" do
|
|
expect(field.possible_values_options)
|
|
.to contain_exactly([option1.value, option1.id.to_s], [option2.value, option2.id.to_s])
|
|
end
|
|
end
|
|
|
|
context "for a version custom field" do
|
|
let(:versions) { [build_stubbed(:version, project:), build_stubbed(:version, project:)] }
|
|
let(:shared_versions_scope) { instance_double(ActiveRecord::Relation) }
|
|
|
|
before do
|
|
field.field_format = "version"
|
|
allow(shared_versions_scope)
|
|
.to receive(:references)
|
|
.with(:project)
|
|
.and_return(versions)
|
|
end
|
|
|
|
context "with a project provided" do
|
|
it "returns the project's shared_versions" do
|
|
allow(project)
|
|
.to receive(:shared_versions)
|
|
.and_return(shared_versions_scope)
|
|
|
|
expect(field.possible_values_options(project))
|
|
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
|
|
end
|
|
end
|
|
|
|
context "with a time entry provided" do
|
|
let(:time_entry) { build_stubbed(:time_entry, project:) }
|
|
|
|
it "returns the project's shared_versions" do
|
|
allow(project)
|
|
.to receive(:shared_versions)
|
|
.and_return(shared_versions_scope)
|
|
|
|
expect(field.possible_values_options(project))
|
|
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
|
|
end
|
|
end
|
|
|
|
context "with nothing provided" do
|
|
context "and no scope provided" do
|
|
it "returns the systemwide versions" do
|
|
allow(Version)
|
|
.to receive(:systemwide)
|
|
.and_return(shared_versions_scope)
|
|
|
|
expect(field.possible_values_options)
|
|
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
|
|
end
|
|
end
|
|
|
|
context "and scope: :visible is provided" do
|
|
it "returns the visible and systemwide versions" do
|
|
allow(Version).to receive(:visible).and_return(shared_versions_scope)
|
|
allow(shared_versions_scope).to receive(:or)
|
|
.with(Version.systemwide)
|
|
.and_return(shared_versions_scope)
|
|
|
|
expect(field.possible_values_options(options: { scope: :visible }))
|
|
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#possible_values" do
|
|
context "on a list custom field" do
|
|
let(:field) { described_class.new field_format: "list" }
|
|
|
|
context "on providing an array" do
|
|
before do
|
|
field.possible_values = ["One value", "Two values", ""]
|
|
end
|
|
|
|
it "accepts the values" do
|
|
expect(field.possible_values.map(&:value))
|
|
.to contain_exactly("One value", "Two values")
|
|
end
|
|
end
|
|
|
|
context "on providing a string" do
|
|
before do
|
|
field.possible_values = "One value"
|
|
end
|
|
|
|
it "accepts the values" do
|
|
expect(field.possible_values.map(&:value))
|
|
.to contain_exactly("One value")
|
|
end
|
|
end
|
|
|
|
context "on providing a multiline string" do
|
|
before do
|
|
field.possible_values = "One value\nTwo values \r\n \n"
|
|
end
|
|
|
|
it "accepts the values" do
|
|
expect(field.possible_values.map(&:value))
|
|
.to contain_exactly("One value", "Two values")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "nested attributes for custom options" do
|
|
let(:option) { build(:custom_option) }
|
|
let(:options) { [option] }
|
|
let(:field) { build(:custom_field, field_format: "list", custom_options: options) }
|
|
|
|
before do
|
|
field.save!
|
|
end
|
|
|
|
shared_examples_for "saving updates field's updated_at" do
|
|
it "updates updated_at" do
|
|
timestamp_before = field.updated_at
|
|
sleep 0.001
|
|
field.save
|
|
expect(field.updated_at).not_to eql(timestamp_before)
|
|
end
|
|
end
|
|
|
|
context "after adding a custom option" do
|
|
before do
|
|
field.attributes = { "custom_options_attributes" => { "0" => option.attributes,
|
|
"1" => { value: "blubs" } } }
|
|
end
|
|
|
|
it_behaves_like "saving updates field's updated_at"
|
|
end
|
|
|
|
context "after changing a custom option" do
|
|
before do
|
|
attributes = option.attributes.merge(value: "new_value")
|
|
|
|
field.attributes = { "custom_options_attributes" => { "0" => attributes } }
|
|
end
|
|
|
|
it_behaves_like "saving updates field's updated_at"
|
|
end
|
|
end
|
|
|
|
describe "#multi_value_possible?" do
|
|
context "with a wp list cf" do
|
|
let(:field) { build_stubbed(:list_wp_custom_field) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a wp user cf" do
|
|
let(:field) { build_stubbed(:user_wp_custom_field) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a wp int cf" do
|
|
let(:field) { build_stubbed(:integer_wp_custom_field) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a project list cf" do
|
|
let(:field) { build_stubbed(:list_project_custom_field) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a project user cf" do
|
|
let(:field) { build_stubbed(:user_project_custom_field) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a project int cf" do
|
|
let(:field) { build_stubbed(:integer_project_custom_field) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a project calculated value cf" do
|
|
let(:field) { build_stubbed(:calculated_value_project_custom_field) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a time_entry user cf" do
|
|
let(:field) { build_stubbed(:time_entry_custom_field, :user) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_multi_value_possible
|
|
end
|
|
end
|
|
|
|
context "with a time_entry list cf" do
|
|
let(:field) { build_stubbed(:time_entry_custom_field, :list) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_multi_value_possible
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#allow_non_open_versions?" do
|
|
context "with a wp list cf" do
|
|
let(:field) { build_stubbed(:list_wp_custom_field) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a wp user cf" do
|
|
let(:field) { build_stubbed(:user_wp_custom_field) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a wp int cf" do
|
|
let(:field) { build_stubbed(:integer_wp_custom_field) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a work package user cf" do
|
|
let(:field) { build_stubbed(:wp_custom_field, :user) }
|
|
|
|
it "is false" do
|
|
expect(field)
|
|
.not_to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a work package version cf" do
|
|
let(:field) { build_stubbed(:wp_custom_field, :version) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a version cf for version" do
|
|
let(:field) { build_stubbed(:version_custom_field, :version) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a project version cf" do
|
|
let(:field) { build_stubbed(:project_custom_field, :version) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
|
|
context "with a time entry version cf" do
|
|
let(:field) { build_stubbed(:time_entry_custom_field, :version) }
|
|
|
|
it "is true" do
|
|
expect(field)
|
|
.to be_allow_non_open_versions_possible
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#destroy" do
|
|
it "removes the cf" do
|
|
field.save!
|
|
|
|
field.destroy
|
|
expect(described_class.where(id: field.id)).not_to exist
|
|
end
|
|
end
|
|
|
|
describe "can_have_comment? instance and class methods" do
|
|
context "for project custom field" do
|
|
let(:instance) { build_stubbed(:project_custom_field) }
|
|
|
|
context "for instance" do
|
|
it { expect(instance).to be_can_have_comment }
|
|
end
|
|
|
|
context "for class" do
|
|
it { expect(instance.class).to be_can_have_comment }
|
|
end
|
|
end
|
|
|
|
{
|
|
wp_custom_field: "work package",
|
|
user_custom_field: "user",
|
|
version_custom_field: "version",
|
|
custom_field: "base"
|
|
}.each do |factory, name|
|
|
context "for #{name} custom field" do
|
|
let(:instance) { build_stubbed(factory) }
|
|
|
|
context "for instance" do
|
|
it { expect(instance).not_to be_can_have_comment }
|
|
end
|
|
|
|
context "for class" do
|
|
it { expect(instance.class).not_to be_can_have_comment }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#comment_for" do
|
|
let(:field) { build_stubbed(:project_custom_field) }
|
|
let(:customized) { build_stubbed(:project) }
|
|
|
|
before { allow(field).to receive(:comments).and_return(comments) }
|
|
|
|
context "when there are no comments" do
|
|
let(:comments) { [] }
|
|
|
|
it "returns nil" do
|
|
expect(field.comment_for(customized)).to be_nil
|
|
end
|
|
end
|
|
|
|
context "when comments exist only for other customized" do
|
|
let(:comments) { [build_stubbed(:custom_comment, customized: build_stubbed(:project), custom_field: field)] }
|
|
|
|
it "returns nil" do
|
|
expect(field.comment_for(customized)).to be_nil
|
|
end
|
|
end
|
|
|
|
context "when comment exists for the customized" do
|
|
let(:comment) { build_stubbed(:custom_comment, customized:, custom_field: field) }
|
|
let(:other_comment) { build_stubbed(:custom_comment, customized: build_stubbed(:project), custom_field: field) }
|
|
let(:comments) { [other_comment, comment] }
|
|
|
|
it "returns the matching comment" do
|
|
expect(field.comment_for(customized)).to eq(comment)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#cast_value" do
|
|
describe "handling all registered formats" do
|
|
before do
|
|
allow(Principal).to receive(:find_by).with(id: 1).and_return(build(:user))
|
|
allow(Version).to receive(:find_by).with(id: 1).and_return(build(:version))
|
|
allow(CustomField::Hierarchy::Item).to receive(:find_by).with(id: 1).and_return(build(:hierarchy_item))
|
|
end
|
|
|
|
OpenProject::CustomFieldFormat.registered.map(&:name).each do |field_format|
|
|
it "handles custom field with format #{field_format}" do
|
|
field = build(:custom_field, field_format:)
|
|
|
|
input = field_format == "date" ? "2025.10.27" : "1"
|
|
|
|
if field_format == "empty"
|
|
expect(field.cast_value(input)).to be_nil
|
|
else
|
|
expect(field.cast_value(input)).not_to be_nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|