mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
[#73523] Implement WorkPackage semantic ID allocation system
This commit is contained in:
@@ -169,6 +169,10 @@ Rails/ContentTag:
|
||||
# dynamic finders cop clashes with capybara ID cop
|
||||
Rails/DynamicFindBy:
|
||||
Enabled: true
|
||||
AllowedMethods:
|
||||
- find_by_id_or_identifier
|
||||
- find_by_id_or_identifier!
|
||||
- find_by_semantic_identifier
|
||||
Exclude:
|
||||
- "spec/features/**/*.rb"
|
||||
- "spec/support/**/*.rb"
|
||||
|
||||
@@ -39,6 +39,7 @@ class Project < ApplicationRecord
|
||||
include Projects::WorkPackageCustomFields
|
||||
include Projects::CreationWizard
|
||||
include Projects::Identifier
|
||||
include Projects::SemanticIdentifier
|
||||
|
||||
include ::Scopes::Scoped
|
||||
|
||||
|
||||
@@ -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.
|
||||
#++
|
||||
|
||||
module Projects::SemanticIdentifier
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Atomically allocates the next sequence number for a work package in this project
|
||||
# and returns it paired with the resulting semantic identifier (e.g. [42, "PROJ-42"]).
|
||||
# Uses an advisory lock scoped to this project to serialize concurrent allocations
|
||||
# without blocking unrelated project row writes.
|
||||
def allocate_wp_semantic_identifier!
|
||||
seq = OpenProject::Mutex.with_advisory_lock(
|
||||
self.class,
|
||||
"wp_sequence_#{id}"
|
||||
) do
|
||||
self.class.connection.select_value(<<~SQL.squish)
|
||||
UPDATE projects
|
||||
SET wp_sequence_counter = wp_sequence_counter + 1
|
||||
WHERE id = #{self.class.connection.quote(id)}
|
||||
RETURNING wp_sequence_counter
|
||||
SQL
|
||||
end
|
||||
|
||||
[seq, "#{identifier}-#{seq}"]
|
||||
end
|
||||
|
||||
# Called after this project's identifier is renamed. Atomically:
|
||||
# 1. Appends new-prefix aliases for every WP that ever carried an old-prefix alias.
|
||||
# 2. Updates identifier on resident WPs to the new prefix.
|
||||
def handle_semantic_rename(old_identifier, batch_size: 1000)
|
||||
like_pattern = "#{self.class.sanitize_sql_like(old_identifier)}-%"
|
||||
prefix = "#{old_identifier}-"
|
||||
new_prefix = "#{identifier}-"
|
||||
|
||||
WorkPackageSemanticAlias.transaction do
|
||||
append_aliases_with_new_prefix(like_pattern:, prefix:, new_prefix:, batch_size:)
|
||||
rewrite_semantic_ids(like_pattern:, prefix:, new_prefix:, batch_size:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# For every alias row whose identifier starts with the old prefix, inserts a
|
||||
# corresponding row with the new prefix. This covers WPs still in the project
|
||||
# as well as any that have moved out but still carry old-prefix alias rows.
|
||||
def append_aliases_with_new_prefix(like_pattern:, prefix:, new_prefix:, batch_size:)
|
||||
WorkPackageSemanticAlias
|
||||
.where("identifier LIKE ?", like_pattern)
|
||||
.in_batches(of: batch_size) do |relation|
|
||||
now = Time.current
|
||||
WorkPackageSemanticAlias.connection.execute(
|
||||
WorkPackageSemanticAlias.sanitize_sql([<<~SQL.squish, { prefix:, new_prefix:, now: }])
|
||||
INSERT INTO work_package_semantic_aliases (identifier, work_package_id, created_at, updated_at)
|
||||
SELECT REPLACE(identifier, :prefix, :new_prefix), work_package_id, :now, :now
|
||||
FROM (#{relation.to_sql}) AS batch
|
||||
ON CONFLICT (identifier) DO NOTHING
|
||||
SQL
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Updates the identifier column on all resident WPs to replace the old prefix with the new one.
|
||||
def rewrite_semantic_ids(like_pattern:, prefix:, new_prefix:, batch_size:)
|
||||
WorkPackage
|
||||
.where("identifier LIKE ?", like_pattern)
|
||||
.in_batches(of: batch_size) do |relation|
|
||||
relation.update_all(["identifier = REPLACE(identifier, ?, ?)", prefix, new_prefix])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,7 @@
|
||||
#++
|
||||
|
||||
class WorkPackage < ApplicationRecord
|
||||
include WorkPackage::SemanticIdentifier
|
||||
include WorkPackage::Validations
|
||||
include WorkPackage::SchedulingRules
|
||||
include WorkPackage::StatusTransitions
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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 WorkPackage::SemanticIdentifier
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :semantic_aliases,
|
||||
class_name: "WorkPackageSemanticAlias",
|
||||
foreign_key: :work_package_id,
|
||||
inverse_of: :work_package,
|
||||
dependent: :delete_all
|
||||
|
||||
after_create :allocate_and_register_semantic_id, if: -> { Setting::WorkPackageIdentifier.semantic? }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def semantic_id?(identifier)
|
||||
identifier.to_s.to_i.to_s != identifier.to_s
|
||||
end
|
||||
|
||||
# Resolves any identifier form to a WorkPackage.
|
||||
# - Numeric string ("12345") → find by primary key
|
||||
# - Semantic string ("PROJ-42") → lookup via work_packages table and alias table
|
||||
#
|
||||
# Returns nil on miss.
|
||||
def find_by_id_or_identifier(identifier)
|
||||
return find_by(id: identifier) unless semantic_id?(identifier)
|
||||
|
||||
find_by_semantic_identifier(identifier)
|
||||
end
|
||||
|
||||
# Same as find_by_id_or_identifier but raises ActiveRecord::RecordNotFound on miss.
|
||||
def find_by_id_or_identifier!(identifier)
|
||||
find_by_id_or_identifier(identifier) || raise(ActiveRecord::RecordNotFound, "WorkPackage not found: #{identifier}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_by_semantic_identifier(identifier)
|
||||
wp = find_by(identifier:)
|
||||
return wp if wp
|
||||
|
||||
# Fallback: alias table lookup. The table holds every identifier a WP has ever been known by:
|
||||
# Done via a single join to:
|
||||
# * Respect any parent scoping (e.g. when called as WorkPackage.visible.find_by_semantic_identifier)
|
||||
# * Reduce lookup to a single DB round trip
|
||||
joins(:semantic_aliases).find_by(work_package_semantic_aliases: { identifier: })
|
||||
end
|
||||
end
|
||||
|
||||
# Allocates the next semantic identifier in the current project and assigns it to the WP.
|
||||
# Also writes alias rows for every identifier the project has ever used (including "ghost" aliases).
|
||||
#
|
||||
# This should generally be run following project_id-mutating operations on WorkPackage records (like create or move).
|
||||
def allocate_and_register_semantic_id
|
||||
WorkPackageSemanticAlias.transaction do
|
||||
sequence_number, identifier = project.allocate_wp_semantic_identifier!
|
||||
# Re-map the semantic identifier to the new project
|
||||
update_columns(sequence_number:, identifier:)
|
||||
# Insert current, historical + ghost aliases for the new project
|
||||
# Note: In case of WP move, the previous mapping for the old project is assumed
|
||||
# to be present in the alias table already, ever since its prior create/move operation.
|
||||
semantic_aliases.insert_all(alias_rows_for_sequence_number(sequence_number),
|
||||
unique_by: :identifier)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Builds alias rows for every identifier this project has ever used at the given sequence (including the current one).
|
||||
# This also includes "ghost identifiers" -- i.e. those that weren't ever actually generated, but should work
|
||||
# as a historical alias (e.g. OLDPROJ-42 should work even if WP #42 was created after rename to NEWPROJ)
|
||||
def alias_rows_for_sequence_number(seq)
|
||||
project.slugs
|
||||
.pluck(:slug)
|
||||
.map { |prefix| { identifier: "#{prefix}-#{seq}", work_package_id: id } }
|
||||
end
|
||||
end
|
||||
@@ -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.
|
||||
#++
|
||||
|
||||
# Maps a semantic identifier (e.g. "PROJ-42") to a work package.
|
||||
# This acts as a registry of all semantic identifiers for a work package,
|
||||
# including both the current identifier and any retired ones created by moves
|
||||
# or project renames. The current identifier is also stored directly on
|
||||
# work_packages.identifier for faster access.
|
||||
#
|
||||
# The write side of the registry lives in WorkPackage::SemanticIdentifier:
|
||||
# wp.allocate_and_register_semantic_id # on WP project change (call post-save)
|
||||
# project.handle_semantic_rename(old_identifier) # on project identifier change
|
||||
class WorkPackageSemanticAlias < ApplicationRecord
|
||||
belongs_to :work_package, inverse_of: :semantic_aliases
|
||||
|
||||
validates :identifier, presence: true, uniqueness: true
|
||||
validates :work_package, presence: true
|
||||
end
|
||||
@@ -57,6 +57,7 @@ module Projects
|
||||
ret = super
|
||||
|
||||
touch_on_custom_values_update
|
||||
update_semantic_ids_on_identifier_change if Setting::WorkPackageIdentifier.semantic?
|
||||
notify_on_identifier_renamed
|
||||
send_update_notification
|
||||
update_wp_versions_on_parent_change
|
||||
@@ -69,6 +70,13 @@ module Projects
|
||||
model.touch if only_custom_values_updated?
|
||||
end
|
||||
|
||||
def update_semantic_ids_on_identifier_change
|
||||
return unless memoized_changes["identifier"]
|
||||
|
||||
old_identifier = memoized_changes["identifier"].first
|
||||
model.handle_semantic_rename(old_identifier)
|
||||
end
|
||||
|
||||
def notify_on_identifier_renamed
|
||||
return unless memoized_changes["identifier"]
|
||||
|
||||
|
||||
@@ -102,12 +102,17 @@ class WorkPackages::UpdateService < BaseServices::Update
|
||||
delete_relations(moved_work_packages)
|
||||
move_time_entries(moved_work_packages, work_package.project_id)
|
||||
move_work_package_memberships(moved_work_packages, work_package.project_id)
|
||||
update_semantic_ids(moved_work_packages) if Setting::WorkPackageIdentifier.semantic?
|
||||
end
|
||||
if work_package.saved_change_to_type_id?
|
||||
reset_custom_values(work_package)
|
||||
end
|
||||
end
|
||||
|
||||
def update_semantic_ids(work_packages)
|
||||
work_packages.each(&:allocate_and_register_semantic_id)
|
||||
end
|
||||
|
||||
def delete_relations(work_packages)
|
||||
unless Setting.cross_project_work_package_relations?
|
||||
Relation
|
||||
|
||||
@@ -1622,6 +1622,9 @@ en:
|
||||
|
||||
activerecord:
|
||||
attributes:
|
||||
work_package_semantic_alias:
|
||||
identifier: "Identifier"
|
||||
work_package: "Work package"
|
||||
jira_import:
|
||||
projects: "Projects"
|
||||
"import/jira":
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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 CreateWorkPackageSemanticIds < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Atomic counter for per-project WP sequence allocation
|
||||
add_column :projects, :wp_sequence_counter, :integer, default: 0, null: false, if_not_exists: true
|
||||
|
||||
# Per-project sequence number for semantic identifiers (e.g. PROJ-42)
|
||||
add_column :work_packages, :sequence_number, :integer, if_not_exists: true
|
||||
# Current semantic identifier stored directly on the work package (e.g. "PROJ-42")
|
||||
add_column :work_packages, :identifier, :string, if_not_exists: true
|
||||
|
||||
create_table :work_package_semantic_aliases, if_not_exists: true do |t|
|
||||
t.string :identifier, null: false
|
||||
t.references :work_package, null: false, foreign_key: true
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Unique identifier across all WPs (past and present)
|
||||
add_index :work_package_semantic_aliases, :identifier, unique: true, if_not_exists: true
|
||||
|
||||
# Fast lookup and uniqueness of the current semantic identifier (partial: excludes pre-backfill NULLs)
|
||||
add_index :work_packages, :identifier,
|
||||
unique: true,
|
||||
where: "identifier IS NOT NULL",
|
||||
if_not_exists: true
|
||||
|
||||
# Enforce uniqueness of sequence numbers within a project (partial: excludes pre-backfill NULLs)
|
||||
add_index :work_packages, %i[project_id sequence_number],
|
||||
unique: true,
|
||||
where: "sequence_number IS NOT NULL",
|
||||
if_not_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :work_package_semantic_aliases, if_exists: true
|
||||
remove_index :work_packages, %i[project_id sequence_number], if_exists: true
|
||||
remove_column :work_packages, :identifier, if_exists: true
|
||||
remove_column :work_packages, :sequence_number, if_exists: true
|
||||
remove_column :projects, :wp_sequence_counter, if_exists: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,140 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe Projects::SemanticIdentifier, with_settings: { work_packages_identifier: "semantic" } do
|
||||
describe "#allocate_wp_semantic_identifier!" do
|
||||
let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) }
|
||||
|
||||
it "returns the allocated sequence number and semantic identifier" do
|
||||
seq, identifier = project.allocate_wp_semantic_identifier!
|
||||
expect(seq).to eq(1)
|
||||
expect(identifier).to eq("PROJ-1")
|
||||
end
|
||||
|
||||
it "increments the counter on each successive call" do
|
||||
project.allocate_wp_semantic_identifier!
|
||||
seq, identifier = project.allocate_wp_semantic_identifier!
|
||||
expect(seq).to eq(2)
|
||||
expect(identifier).to eq("PROJ-2")
|
||||
end
|
||||
|
||||
it "persists the updated counter to the database" do
|
||||
project.allocate_wp_semantic_identifier!
|
||||
expect(project.reload.wp_sequence_counter).to eq(1)
|
||||
end
|
||||
|
||||
it "uses the current project identifier as the prefix" do
|
||||
project.update_columns(identifier: "NEWPROJ")
|
||||
_, identifier = project.allocate_wp_semantic_identifier!
|
||||
expect(identifier).to eq("NEWPROJ-1")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#handle_semantic_rename" do
|
||||
let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) }
|
||||
let(:target_project) { create(:project, identifier: "OTHER", wp_sequence_counter: 0) }
|
||||
let(:wp1) { create(:work_package, project:) }
|
||||
let(:wp2) { create(:work_package, project:) }
|
||||
|
||||
before do
|
||||
wp1
|
||||
wp2
|
||||
project.update_columns(identifier: "NEWPROJ")
|
||||
end
|
||||
|
||||
it "preserves old-prefix aliases for resident WPs" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-2")).to be_present
|
||||
end
|
||||
|
||||
it "adds new-prefix aliases for resident WPs" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-1")).to be_present
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-2")).to be_present
|
||||
end
|
||||
|
||||
it "updates identifier on resident WPs to the new prefix" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect(wp1.reload.identifier).to eq("NEWPROJ-1")
|
||||
expect(wp2.reload.identifier).to eq("NEWPROJ-2")
|
||||
end
|
||||
|
||||
it "is idempotent (safe to run twice)" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect { project.handle_semantic_rename("PROJ") }.not_to raise_error
|
||||
end
|
||||
|
||||
context "when records span multiple batches" do
|
||||
let(:wp3) { create(:work_package, project:) }
|
||||
|
||||
before { wp3 }
|
||||
|
||||
it "processes all aliases across batch boundaries" do
|
||||
project.handle_semantic_rename("PROJ", batch_size: 2)
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-1")).to be_present
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-2")).to be_present
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-3")).to be_present
|
||||
end
|
||||
|
||||
it "rewrites all WP identifiers across batch boundaries" do
|
||||
project.handle_semantic_rename("PROJ", batch_size: 2)
|
||||
expect(wp1.reload.identifier).to eq("NEWPROJ-1")
|
||||
expect(wp2.reload.identifier).to eq("NEWPROJ-2")
|
||||
expect(wp3.reload.identifier).to eq("NEWPROJ-3")
|
||||
end
|
||||
end
|
||||
|
||||
context "when a WP has previously moved out of the project" do
|
||||
before do
|
||||
# Move wp1 to OTHER properly so "PROJ-1" ends up as an alias
|
||||
wp1.update_columns(project_id: target_project.id)
|
||||
wp1.allocate_and_register_semantic_id
|
||||
end
|
||||
|
||||
it "appends a new-prefix alias derived from the old alias row" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "NEWPROJ-1")).to be_present
|
||||
end
|
||||
|
||||
it "preserves the original old-prefix alias" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present
|
||||
end
|
||||
|
||||
it "does not update identifier on the moved-away WP" do
|
||||
project.handle_semantic_rename("PROJ")
|
||||
expect(wp1.reload.identifier).to eq("OTHER-1")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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 WorkPackage::SemanticIdentifier do
|
||||
let(:project) { create(:project, identifier: "MYPROJ") }
|
||||
# Creating a WP in alphanumeric mode auto-registers it: gets sequence_number 1 and entry "MYPROJ-1".
|
||||
let(:work_package) { create(:work_package, project:) }
|
||||
|
||||
before do
|
||||
allow(Setting::WorkPackageIdentifier).to receive_messages(semantic?: true, classic?: false)
|
||||
work_package
|
||||
end
|
||||
|
||||
describe "after_create registration" do
|
||||
it "assigns a sequence number" do
|
||||
expect(work_package.reload.sequence_number).to eq(1)
|
||||
end
|
||||
|
||||
it "sets identifier on the work package" do
|
||||
expect(work_package.reload.identifier).to eq("MYPROJ-1")
|
||||
end
|
||||
|
||||
it "creates a registry entry for the initial identifier" do
|
||||
expect(work_package.semantic_aliases.pluck(:identifier)).to contain_exactly("MYPROJ-1")
|
||||
end
|
||||
|
||||
it "increments the counter for each successive WP" do
|
||||
wp2 = create(:work_package, project:)
|
||||
expect(wp2.reload.sequence_number).to eq(2)
|
||||
expect(wp2.reload.identifier).to eq("MYPROJ-2")
|
||||
end
|
||||
end
|
||||
|
||||
describe "WorkPackage.find_by_id_or_identifier" do
|
||||
context "with a numeric param" do
|
||||
it "finds by primary key" do
|
||||
expect(WorkPackage.find_by_id_or_identifier(work_package.id.to_s)).to eq(work_package)
|
||||
end
|
||||
|
||||
it "returns nil for unknown id" do
|
||||
expect(WorkPackage.find_by_id_or_identifier("9999999")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "with a semantic param" do
|
||||
context "when the identifier matches work_packages.identifier (fast path)" do
|
||||
it "finds directly via identifier without hitting the alias table" do
|
||||
expect(WorkPackage.find_by_id_or_identifier("MYPROJ-1")).to eq(work_package)
|
||||
end
|
||||
|
||||
it "returns nil when no WP has that identifier and no alias or fallback matches" do
|
||||
expect(WorkPackage.find_by_id_or_identifier("MYPROJ-999")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when the identifier is a historic alias (alias table path)" do
|
||||
it "resolves historic entries via the alias registry" do
|
||||
WorkPackageSemanticAlias.create!(identifier: "OLDPROJ-1", work_package:)
|
||||
expect(WorkPackage.find_by_id_or_identifier("OLDPROJ-1")).to eq(work_package)
|
||||
end
|
||||
|
||||
it "resolves when identifier differs but an alias row exists" do
|
||||
work_package.update_columns(identifier: "OTHER-99")
|
||||
expect(WorkPackage.find_by_id_or_identifier("MYPROJ-1")).to eq(work_package)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns nil for unknown sequence" do
|
||||
expect(WorkPackage.find_by_id_or_identifier("MYPROJ-999")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for unknown project prefix" do
|
||||
expect(WorkPackage.find_by_id_or_identifier("NOPE-1")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for an unparseable string" do
|
||||
expect(WorkPackage.find_by_id_or_identifier("not-an-identifier!")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "with visibility scoping" do
|
||||
let(:member_user) { create(:user, member_with_permissions: { project => [:view_work_packages] }) }
|
||||
let(:non_member_user) { create(:user) }
|
||||
|
||||
it "returns the WP for a user who can see it" do
|
||||
expect(WorkPackage.visible(member_user).find_by_id_or_identifier("MYPROJ-1")).to eq(work_package)
|
||||
end
|
||||
|
||||
it "returns nil for a user who cannot see it" do
|
||||
expect(WorkPackage.visible(non_member_user).find_by_id_or_identifier("MYPROJ-1")).to be_nil
|
||||
end
|
||||
|
||||
it "also scopes numeric lookup" do
|
||||
expect(WorkPackage.visible(non_member_user).find_by_id_or_identifier(work_package.id.to_s)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "WorkPackage.find_by_id_or_identifier!" do
|
||||
it "returns the work package when found" do
|
||||
expect(WorkPackage.find_by_id_or_identifier!(work_package.id.to_s)).to eq(work_package)
|
||||
end
|
||||
|
||||
it "raises ActiveRecord::RecordNotFound when not found" do
|
||||
expect { WorkPackage.find_by_id_or_identifier!("MYPROJ-999") }
|
||||
.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#allocate_and_register_semantic_id" do
|
||||
let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) }
|
||||
let(:target_project) { create(:project, identifier: "OTHER", wp_sequence_counter: 0) }
|
||||
|
||||
before do
|
||||
work_package.update_columns(project_id: target_project.id)
|
||||
end
|
||||
|
||||
it "preserves the old identifier as a historical alias (written at creation)" do
|
||||
work_package.allocate_and_register_semantic_id
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present
|
||||
end
|
||||
|
||||
it "updates sequence_number and identifier to the target project's values" do
|
||||
work_package.allocate_and_register_semantic_id
|
||||
expect(work_package.reload.sequence_number).to eq(1)
|
||||
expect(work_package.reload.identifier).to eq("OTHER-1")
|
||||
end
|
||||
|
||||
it "adds the new identifier to the alias table" do
|
||||
work_package.allocate_and_register_semantic_id
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "OTHER-1")).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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 WorkPackageSemanticAlias do
|
||||
let(:work_package) { create(:work_package) }
|
||||
|
||||
describe "validations" do
|
||||
it "is valid with an identifier and work_package" do
|
||||
record = described_class.new(identifier: "PROJ-1", work_package:)
|
||||
expect(record).to be_valid
|
||||
end
|
||||
|
||||
it "requires identifier" do
|
||||
record = described_class.new(work_package:)
|
||||
expect(record).not_to be_valid
|
||||
expect(record.errors[:identifier]).to be_present
|
||||
end
|
||||
|
||||
it "requires work_package" do
|
||||
record = described_class.new(identifier: "PROJ-1")
|
||||
expect(record).not_to be_valid
|
||||
expect(record.errors[:work_package]).to be_present
|
||||
end
|
||||
|
||||
it "enforces identifier uniqueness" do
|
||||
described_class.create!(identifier: "PROJ-1", work_package:)
|
||||
duplicate = described_class.new(identifier: "PROJ-1", work_package:)
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:identifier]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe "associations" do
|
||||
it "belongs to a work_package" do
|
||||
record = described_class.create!(identifier: "PROJ-1", work_package:)
|
||||
expect(record.work_package).to eq(work_package)
|
||||
end
|
||||
end
|
||||
|
||||
describe WorkPackage do
|
||||
describe "#semantic_aliases" do
|
||||
let(:wp) { create(:work_package) }
|
||||
|
||||
it "exposes all registry entries" do
|
||||
entry1 = WorkPackageSemanticAlias.create!(identifier: "PROJ-1", work_package: wp)
|
||||
entry2 = WorkPackageSemanticAlias.create!(identifier: "OLD-1", work_package: wp)
|
||||
|
||||
expect(wp.semantic_aliases).to contain_exactly(entry1, entry2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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"
|
||||
|
||||
# End-to-end tests verifying that the registry is maintained correctly through
|
||||
# the full service stack: CreateService, UpdateService, and Projects::UpdateService.
|
||||
RSpec.describe "SemanticIds registry integration", type: :model do
|
||||
shared_let(:role) do
|
||||
create(:project_role,
|
||||
permissions: %i[view_work_packages add_work_packages edit_work_packages move_work_packages edit_project])
|
||||
end
|
||||
shared_let(:user) { create(:user) }
|
||||
|
||||
# Projects with uppercase identifiers require alphanumeric mode — stub before creating.
|
||||
let(:project) { create(:project, identifier: "PROJ", wp_sequence_counter: 0) }
|
||||
let(:target_project) { create(:project, identifier: "DEST", wp_sequence_counter: 0) }
|
||||
|
||||
before do
|
||||
allow(Setting::WorkPackageIdentifier).to receive_messages(semantic?: true, classic?: false)
|
||||
create(:member, principal: user, project:, roles: [role])
|
||||
create(:member, principal: user, project: target_project, roles: [role])
|
||||
login_as(user)
|
||||
end
|
||||
|
||||
describe "WP creation via CreateService" do
|
||||
let(:attributes) do
|
||||
{
|
||||
subject: "A new task",
|
||||
project:,
|
||||
type: project.types.first,
|
||||
status: create(:default_status),
|
||||
priority: create(:default_priority)
|
||||
}
|
||||
end
|
||||
|
||||
it "assigns a sequence number, sets identifier, and registers all project-prefix aliases" do
|
||||
result = WorkPackages::CreateService.new(user:).call(**attributes)
|
||||
expect(result).to be_success
|
||||
|
||||
wp = result.result
|
||||
expect(wp.sequence_number).to eq(1)
|
||||
expect(wp.identifier).to eq("PROJ-1")
|
||||
expect(WorkPackageSemanticAlias.find_by!(work_package: wp).identifier).to eq("PROJ-1")
|
||||
end
|
||||
|
||||
it "increments the counter with each new WP" do
|
||||
2.times { WorkPackages::CreateService.new(user:).call(**attributes) }
|
||||
expect(project.reload.wp_sequence_counter).to eq(2)
|
||||
expect(WorkPackageSemanticAlias.where("identifier LIKE 'PROJ-%'").count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "WP move via UpdateService" do
|
||||
let!(:work_package) do
|
||||
# after_create auto-registers as PROJ-1; rename entry to PROJ-5 to simulate an established WP
|
||||
create(:work_package, project:).tap do |wp|
|
||||
wp.update_columns(sequence_number: 5, identifier: "PROJ-5")
|
||||
wp.semantic_aliases.update_all(identifier: "PROJ-5")
|
||||
project.update_columns(wp_sequence_counter: 5)
|
||||
end
|
||||
end
|
||||
|
||||
it "preserves the old identifier and appends a new one in the target project" do
|
||||
WorkPackages::UpdateService.new(user:, model: work_package).call(project: target_project)
|
||||
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-5")).to be_present
|
||||
expect(work_package.reload.identifier).to start_with("DEST-")
|
||||
end
|
||||
|
||||
it "old identifier still resolves to the WP" do
|
||||
WorkPackages::UpdateService.new(user:, model: work_package).call(project: target_project)
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-5")).to eq(work_package)
|
||||
end
|
||||
|
||||
it "new identifier also resolves to the WP" do
|
||||
WorkPackages::UpdateService.new(user:, model: work_package).call(project: target_project)
|
||||
expect(WorkPackage.find_by_id_or_identifier(work_package.reload.identifier)).to eq(work_package)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Project rename via Projects::UpdateService" do
|
||||
# after_create auto-registers wp1 as "PROJ-1" (seq=1) and wp2 as "PROJ-2" (seq=2)
|
||||
let!(:wp1) { create(:work_package, project:) }
|
||||
let!(:wp2) { create(:work_package, project:) }
|
||||
|
||||
it "updates identifier on WPs and inserts new-prefix aliases" do
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
|
||||
expect(wp1.reload.identifier).to eq("RENAMED-1")
|
||||
expect(wp2.reload.identifier).to eq("RENAMED-2")
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "RENAMED-1")).to be_present
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "RENAMED-2")).to be_present
|
||||
end
|
||||
|
||||
it "preserves old-prefix entries for historic resolution" do
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-1")).to be_present
|
||||
expect(WorkPackageSemanticAlias.find_by(identifier: "PROJ-2")).to be_present
|
||||
end
|
||||
|
||||
it "old identifiers still resolve to the correct WPs" do
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-1")).to eq(wp1)
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-2")).to eq(wp2)
|
||||
end
|
||||
|
||||
it "new identifiers resolve to the correct WPs" do
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("RENAMED-1")).to eq(wp1)
|
||||
expect(WorkPackage.find_by_id_or_identifier("RENAMED-2")).to eq(wp2)
|
||||
end
|
||||
|
||||
it "old prefix resolves for WPs created after the rename" do
|
||||
# wp3 is created after the rename; register_identifier inserts both RENAMED-3
|
||||
# (current prefix) and PROJ-3 (historical slug), so both resolve via the alias table.
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
wp3 = create(:work_package, project: project.reload)
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("RENAMED-3")).to eq(wp3)
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-3")).to eq(wp3)
|
||||
end
|
||||
end
|
||||
|
||||
describe "rename + move combinations" do
|
||||
let!(:wp1) { create(:work_package, project:) } # auto-registers as PROJ-1
|
||||
|
||||
it "move then rename: old WP identifier resolves under new project prefix" do
|
||||
# WP moves to DEST first (retires PROJ-1, creates DEST-1)
|
||||
WorkPackages::UpdateService.new(user:, model: wp1).call(project: target_project)
|
||||
# PROJ is then renamed to RENAMED (bulk-inserts RENAMED-1 from the retired PROJ-1 row)
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("RENAMED-1")).to eq(wp1)
|
||||
end
|
||||
|
||||
it "rename then move: both old identifiers resolve after the WP moves" do
|
||||
# PROJ renamed to RENAMED (appends RENAMED-1 registry row, updates identifier)
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
# WP moves to DEST (appends DEST-1 registry row, updates identifier)
|
||||
WorkPackages::UpdateService.new(user:, model: wp1.reload).call(project: target_project)
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-1")).to eq(wp1)
|
||||
expect(WorkPackage.find_by_id_or_identifier("RENAMED-1")).to eq(wp1)
|
||||
end
|
||||
|
||||
it "rename then new WP then move: pre-rename identifier resolves via alias table" do
|
||||
# PROJ renamed to RENAMED; wp1 gets alias PROJ-1, identifier becomes RENAMED-1
|
||||
Projects::UpdateService.new(user:, model: project).call(identifier: "RENAMED")
|
||||
# wp2 is created in the now-RENAMED project; register_identifier inserts both
|
||||
# RENAMED-2 (current prefix) and PROJ-2 (historical slug) into the alias table
|
||||
wp2 = create(:work_package, project: project.reload)
|
||||
# wp2 moves to DEST — old identifier RENAMED-2 kept as alias, gets DEST-1
|
||||
WorkPackages::UpdateService.new(user:, model: wp2).call(project: target_project)
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-2")).to eq(wp2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "multiple moves" do
|
||||
let(:project_c) { create(:project, identifier: "PROJC", wp_sequence_counter: 0) }
|
||||
let!(:wp1) { create(:work_package, project:) } # auto-registers as PROJ-1
|
||||
|
||||
before do
|
||||
create(:member, principal: user, project: project_c, roles: [role])
|
||||
end
|
||||
|
||||
it "all intermediate identifiers resolve after WP moves PROJ → DEST → PROJC" do
|
||||
WorkPackages::UpdateService.new(user:, model: wp1).call(project: target_project)
|
||||
dest_identifier = wp1.reload.identifier
|
||||
|
||||
WorkPackages::UpdateService.new(user:, model: wp1.reload).call(project: project_c)
|
||||
projc_identifier = wp1.reload.identifier
|
||||
|
||||
expect(WorkPackage.find_by_id_or_identifier("PROJ-1")).to eq(wp1)
|
||||
expect(WorkPackage.find_by_id_or_identifier(dest_identifier)).to eq(wp1)
|
||||
expect(WorkPackage.find_by_id_or_identifier(projc_identifier)).to eq(wp1)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user