[STC-767] Release old semantic identifiers

https://community.openproject.org/wp/STC-767
This commit is contained in:
Tomas Hykel
2026-06-11 23:20:02 +02:00
parent 24a5b24bcd
commit 4d09cf130e
14 changed files with 471 additions and 64 deletions
@@ -33,6 +33,10 @@
render(
Primer::OpenProject::DangerDialog.new(
id: "release-identifier-dialog",
# The default :medium size caps the dialog height at 320px, which the
# description with the work package count can exceed, forcing an inner
# scrollbar. :medium_portrait keeps the same width with a 600px cap.
size: :medium_portrait,
title: I18n.t("admin.reserved_identifiers.dialog.title"),
confirm_button_text: I18n.t("admin.reserved_identifiers.dialog.confirm_button"),
cancel_button_text: I18n.t("button_cancel"),
@@ -47,9 +51,7 @@
I18n.t("admin.reserved_identifiers.dialog.heading", identifier: @slug.slug)
end
message.with_description_content(
I18n.t("admin.reserved_identifiers.dialog.description")
)
message.with_description_content(description_text)
end
dialog.with_confirmation_check_box_content(
@@ -39,6 +39,21 @@ module Admin
super
@slug = slug
end
private
def description_text
if affected_work_package_count.positive?
I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages",
count: affected_work_package_count)
else
I18n.t("admin.reserved_identifiers.dialog.description")
end
end
def affected_work_package_count
@affected_work_package_count ||= WorkPackage.resolving_via_slug_prefix(@slug.slug).count
end
end
end
end
@@ -34,7 +34,6 @@ module Admin::Settings
include PaginationHelper
before_action :require_admin
before_action :require_classic_mode
before_action :find_slug, only: %i[confirm_dialog destroy]
menu_item :project_reserved_identifiers_settings
@@ -63,7 +62,7 @@ module Admin::Settings
end
def destroy
@slug.destroy!
ProjectIdentifiers::ReleaseReservedIdentifierService.new(@slug).call
redirect_to admin_settings_project_reserved_identifiers_path,
flash: { notice: t("admin.reserved_identifiers.released_notice", identifier: @slug.slug) }
end
@@ -85,12 +84,5 @@ module Admin::Settings
query_class: Queries::ProjectReservedIdentifiers::ProjectReservedIdentifierQuery)
.call(params)
end
def require_classic_mode
return unless Setting::WorkPackageIdentifier.semantic?
redirect_to admin_settings_work_packages_identifier_path,
flash: { warning: t("admin.reserved_identifiers.not_available_in_semantic_mode") }
end
end
end
@@ -37,9 +37,15 @@ class Queries::ProjectReservedIdentifiers::ProjectReservedIdentifierQuery
end
def default_scope
# Pure-numeric slugs are legacy artifacts (identifier validation was tightened
# later; the semantic-conversion autofix renames such projects, reserving the
# old numeric identifier). Releasing them frees nothing — numeric identifiers
# are invalid in both formats — and would break friendly_id history resolution
# of old /projects/<number> links, letting them fall through to a primary-key
# lookup of a different project.
Project.identifier_slugs
.historically_reserved
.where("slug ~ ? AND slug !~ ?", "^[a-z0-9_-]+$", "^[0-9]+$")
.where("slug !~ ?", "^[0-9]+$")
.joins("JOIN projects ON projects.id = friendly_id_slugs.sluggable_id")
.order("projects.name ASC, friendly_id_slugs.created_at DESC")
end
@@ -41,6 +41,16 @@ module WorkPackage::SemanticIdentifier
# The frontend equivalent lives in WP_ID_URL_PATTERN (work-package-id-pattern.ts).
ID_ROUTE_CONSTRAINT = /\d+|#{SEMANTIC_ID_PATTERN.source}/
# Anchored POSIX regex matching identifiers of the exact form "<slug>-<digits>"
# for a concrete project slug, so prefixes containing dashes don't over-match
# (matching "my" must not touch "my-project-42"). Used by the for_slug_prefix
# scopes on WorkPackage and WorkPackageSemanticAlias. Regexp.escape output is
# valid in PostgreSQL's ARE syntax: it backslash-escapes punctuation (which
# ARE treats as literals) and never emits class escapes.
def self.slug_prefix_pattern(slug)
"^#{Regexp.escape(slug)}-[0-9]+$"
end
# Raised when a finder is invoked in a way that cannot resolve a semantic
# identifier — e.g. find_by(id: "PROJ-42") which reduces to a raw SQL
# WHERE clause that cannot consult the alias table. Subclasses ArgumentError
@@ -64,6 +74,21 @@ module WorkPackage::SemanticIdentifier
joins(:project).semantically_sequenced
.where("work_packages.identifier IS DISTINCT FROM projects.identifier || '-' || work_packages.sequence_number::text")
}
# Work packages whose identifier column carries the given project slug
# prefix, i.e. is of the exact form "<slug>-<digits>". Counterpart to
# WorkPackageSemanticAlias.for_slug_prefix for the denormalized column.
scope :for_slug_prefix, ->(slug) {
where("identifier ~ ?", WorkPackage::SemanticIdentifier.slug_prefix_pattern(slug))
}
# Work packages that currently resolve via identifiers of the form
# "<slug>-<digits>" — through the identifier column or an alias row.
# The single-identifier counterpart is FinderMethods#scope_for_semantic_identifier.
# ReleaseReservedIdentifierService severs exactly this set, and the release
# dialog counts it — keep the two in sync through this scope.
scope :resolving_via_slug_prefix, ->(slug) {
where(id: WorkPackageSemanticAlias.for_slug_prefix(slug).select(:work_package_id))
.or(for_slug_prefix(slug))
}
attr_accessor :skip_semantic_id_allocation
@@ -42,4 +42,12 @@ class WorkPackageSemanticAlias < ApplicationRecord
validates :identifier, presence: true, uniqueness: true
validates :work_package, presence: true
# Aliases created for the given project slug prefix, i.e. identifiers of the
# exact form "<slug>-<digits>". Case-sensitive: aliases are always created
# verbatim from slug values, and slugs differing only in case (classic "proj"
# vs semantic "PROJ") are distinct reservations.
scope :for_slug_prefix, ->(slug) {
where("identifier ~ ?", WorkPackage::SemanticIdentifier.slug_prefix_pattern(slug))
}
end
@@ -0,0 +1,72 @@
# 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 ProjectIdentifiers
# Releases a historically reserved project identifier slug.
# Additionally removes the WorkPackageSemanticAlias rows created for that
# slug prefix ("<slug>-<digits>") and clears stale work package identifier
# columns carrying it, atomically with the slug destroy, so the released
# prefix no longer resolves work packages. This happens regardless of the
# current identifier mode: leftovers from a previous semantic phase must not
# survive a release, or they would keep old links resolving and shadow the
# alias rows of a new project claiming the identifier after a later
# re-conversion.
class ReleaseReservedIdentifierService
def initialize(slug)
@slug = slug
end
def call
FriendlyId::Slug.transaction do
delete_aliases
clear_stale_work_package_identifiers
@slug.destroy!
end
ServiceResult.success
end
private
def delete_aliases
WorkPackageSemanticAlias.for_slug_prefix(@slug.slug).delete_all
end
# A historically reserved slug is no project's current identifier, so any
# work package still carrying "<slug>-<digits>" in its identifier column is
# stale (left over from a revert to classic mode). The finder resolves
# semantic identifiers against this column as well as the alias table, so
# clearing it severs resolution immediately instead of waiting for the next
# semantic conversion's reset_stale_identifiers to do the same.
def clear_stale_work_package_identifiers
WorkPackage.for_slug_prefix(@slug.slug).update_all(identifier: nil, sequence_number: nil)
end
end
end
+1 -1
View File
@@ -464,7 +464,7 @@ Redmine::MenuManager.map :admin_menu do |menu|
menu.push :project_reserved_identifiers_settings,
{ controller: "/admin/settings/project_reserved_identifiers", action: :index },
if: ->(_) { User.current.admin? && Setting::WorkPackageIdentifier.classic? },
if: ->(_) { User.current.admin? },
caption: :label_reserved_identifiers,
parent: :admin_projects_settings
+14 -12
View File
@@ -61,25 +61,27 @@ en:
admin:
reserved_identifiers:
title: "Reserved project identifiers"
lede_html: "When a project's identifier is renamed, the previous identifier is kept reserved so that existing links and integrations keep working.<br>Here you can release reserved identifiers so that they may be used by other projects."
btn_release: "Unreserve"
col_identifier: "Identifier"
col_project: "Project"
col_reserved: "Reserved"
not_available_in_semantic_mode: "Reserved project identifiers are only available in numeric identifier mode."
filter_label: "Search identifiers"
btn_release: "Release"
released_notice: 'Identifier "%{identifier}" has been released.'
identifier_not_found: "The reserved identifier could not be found. It may have already been released or the project may have been deleted. Please refresh the page."
dialog:
title: "Release identifier"
heading: 'Release "%{identifier}"?'
description: "Releasing this identifier cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
checkbox_label: "I understand that this cannot be undone."
confirm_button: "Release identifier"
confirm_button: "Unreserve identifier"
description: "Unreserving this project identifier cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
description_with_work_packages:
one: "Unreserving this project identifier (and its 1 work package identifier) cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
other: "Unreserving this project identifier (and its %{count} work package identifiers) cannot be undone. External links and integrations using it will stop resolving, and the name becomes available for any new project to claim."
heading: 'Unreserve "%{identifier}"?'
title: "Unreserve identifier"
empty_body: "When a project's identifier changes, the previous one will appear here so you can unreserve it once it's safe to do so."
empty_heading: "No reserved identifiers"
filter_label: "Search identifiers"
identifier_not_found: "The reserved identifier could not be found. It may have already been unreserved or the project may have been deleted. Please refresh the page."
lede_html: "When a project's identifier is renamed, the previous identifier is kept reserved so that existing links and integrations keep working.<br>Here you can unreserve identifiers so that they may be used by other projects."
released_notice: 'Identifier "%{identifier}" has been unreserved.'
reserved_ago: "%{time} ago"
empty_body: "When a project's identifier changes, the previous one will appear here so you can release it once it's safe to do so."
title: "Reserved project identifiers"
plugins:
no_results_title_text: There are currently no plugins installed.
no_results_content_text: See our integrations and plugins page for more information.
@@ -0,0 +1,72 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe Admin::Settings::ProjectReservedIdentifiers::ReleaseDialogComponent, type: :component do
let!(:project) { create(:project) }
let!(:slug) { FriendlyId::Slug.create!(sluggable: project, slug: "old-id") }
subject(:rendered_component) { render_inline(described_class.new(slug:)) }
it "renders the heading with the identifier" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.heading", identifier: "old-id"))
end
context "without affected work packages" do
it "renders the plain description" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description"))
expect(rendered_component).to have_no_text("work package")
end
end
context "with one affected work package" do
before { create(:work_package_semantic_alias, identifier: "old-id-1") }
it "renders the singular description" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 1))
end
end
context "with several affected work packages" do
before do
create(:work_package_semantic_alias, identifier: "old-id-1")
create(:work_package_semantic_alias, identifier: "old-id-2")
end
it "renders the pluralized description" do
expect(rendered_component)
.to have_text(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 2))
end
end
end
@@ -36,40 +36,33 @@ RSpec.describe Admin::Settings::ProjectReservedIdentifiersController do
current_user { admin }
describe "GET #index" do
context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do
it "redirects to the identifier settings page" do
get :index
expect(response).to redirect_to(admin_settings_work_packages_identifier_path)
end
end
shared_examples "lists reserved slugs of all formats" do
let!(:project) { create(:project) }
before do
FriendlyId::Slug.create!(sluggable: project, slug: "old-classic")
FriendlyId::Slug.create!(sluggable: project, slug: "OLDPROJ")
FriendlyId::Slug.create!(sluggable: project, slug: "12345")
end
context "in classic mode", with_settings: { work_packages_identifier: "classic" } do
it "responds 200" do
get :index
expect(response).to have_http_status(:ok)
end
context "with a classic reserved slug" do
let!(:project) { create(:project, identifier: "current-id") }
before { FriendlyId::Slug.create!(sluggable: project, slug: "old-classic") }
it "includes the slug in @slugs" do
get :index
expect(assigns(:slugs).map(&:slug)).to include("old-classic")
end
it "includes classic-format and semantic-format slugs, excluding pure-numeric ones" do
get :index
expect(assigns(:slugs).map(&:slug)).to include("old-classic", "OLDPROJ")
expect(assigns(:slugs).map(&:slug)).not_to include("12345")
end
end
context "with a pure-numeric reserved slug" do
let!(:project) { create(:project, identifier: "current-id") }
context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do
it_behaves_like "lists reserved slugs of all formats"
end
before { FriendlyId::Slug.create!(sluggable: project, slug: "12345") }
it "excludes pure-numeric slugs" do
get :index
expect(assigns(:slugs).map(&:slug)).not_to include("12345")
end
end
context "in classic mode", with_settings: { work_packages_identifier: "classic" } do
it_behaves_like "lists reserved slugs of all formats"
end
end
@@ -154,26 +147,81 @@ RSpec.describe Admin::Settings::ProjectReservedIdentifiersController do
expect(response.body).to include(I18n.t("admin.reserved_identifiers.identifier_not_found"))
end
end
describe "work package warning" do
render_views
let!(:project) { create(:project) }
let!(:slug) { FriendlyId::Slug.create!(sluggable: project, slug: "old-id") }
context "with affected work packages" do
before do
create(:work_package_semantic_alias, identifier: "old-id-1")
create(:work_package_semantic_alias, identifier: "old-id-2")
end
%w[semantic classic].each do |mode|
context "in #{mode} mode", with_settings: { work_packages_identifier: mode } do
it "shows the warning with the affected work package count" do
get :confirm_dialog, params: { id: slug.id }, format: :turbo_stream
expect(response.body)
.to include(I18n.t("admin.reserved_identifiers.dialog.description_with_work_packages", count: 2))
end
end
end
end
context "without affected work packages" do
it "does not show the warning" do
get :confirm_dialog, params: { id: slug.id }, format: :turbo_stream
expect(response.body).not_to include("work package")
end
end
end
end
describe "DELETE #destroy", with_settings: { work_packages_identifier: "classic" } do
let!(:project) { create(:project, identifier: "current-id") }
describe "DELETE #destroy" do
let!(:project) { create(:project) }
let!(:slug) { FriendlyId::Slug.create!(sluggable: project, slug: "old-id") }
it "destroys the slug and redirects with a flash notice" do
expect { delete :destroy, params: { id: slug.id } }
.to change(FriendlyId::Slug, :count).by(-1)
context "in classic mode", with_settings: { work_packages_identifier: "classic" } do
it "destroys the slug and redirects with a flash notice" do
expect { delete :destroy, params: { id: slug.id } }
.to change(FriendlyId::Slug, :count).by(-1)
expect(response).to redirect_to(admin_settings_project_reserved_identifiers_path)
expect(flash[:notice]).to include("old-id")
expect(response).to redirect_to(admin_settings_project_reserved_identifiers_path)
expect(flash[:notice]).to include("old-id")
end
it "also deletes work package aliases left over from a previous semantic phase" do
create(:work_package_semantic_alias, identifier: "old-id-1")
expect { delete :destroy, params: { id: slug.id } }
.to change(WorkPackageSemanticAlias, :count).by(-1)
end
context "with an unknown id" do
it "responds with a turbo stream error flash" do
delete :destroy, params: { id: 0 }, format: :turbo_stream
expect(response).to have_http_status(:not_found)
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include(I18n.t("admin.reserved_identifiers.identifier_not_found"))
end
end
end
context "with an unknown id" do
it "responds with a turbo stream error flash" do
delete :destroy, params: { id: 0 }, format: :turbo_stream
expect(response).to have_http_status(:not_found)
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include(I18n.t("admin.reserved_identifiers.identifier_not_found"))
# Exact-prefix and case-sensitivity edge cases live in the service and
# scope specs; this guards the formerly mode-gated endpoint end to end.
context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do
it "destroys the slug and deletes its aliases" do
create(:work_package_semantic_alias, identifier: "old-id-1")
expect { delete :destroy, params: { id: slug.id } }
.to change(FriendlyId::Slug, :count).by(-1)
.and change(WorkPackageSemanticAlias, :count).by(-1)
expect(response).to redirect_to(admin_settings_project_reserved_identifiers_path)
expect(flash[:notice]).to include("old-id")
end
end
end
@@ -75,6 +75,39 @@ RSpec.describe WorkPackage::SemanticIdentifier do
end
end
describe ".for_slug_prefix" do
it "matches work packages whose identifier is of the form <slug>-<digits>" do
expect(WorkPackage.for_slug_prefix("MYPROJ")).to include(work_package)
end
it "does not over-match when another slug starts with the given slug plus a dash" do
work_package.update_columns(identifier: "my-project-42")
expect(WorkPackage.for_slug_prefix("my")).not_to include(work_package)
end
it "matches case-sensitively" do
expect(WorkPackage.for_slug_prefix("myproj")).not_to include(work_package)
end
end
describe ".resolving_via_slug_prefix" do
# The auto-registered work_package carries the prefix both in its
# identifier column and as an alias row.
it "returns work packages matched via column and alias exactly once" do
expect(WorkPackage.resolving_via_slug_prefix("MYPROJ")).to contain_exactly(work_package)
end
it "includes work packages matched only via an alias row" do
work_package.update_columns(identifier: nil, sequence_number: nil)
expect(WorkPackage.resolving_via_slug_prefix("MYPROJ")).to contain_exactly(work_package)
end
it "includes work packages matched only via the identifier column" do
work_package.semantic_aliases.delete_all
expect(WorkPackage.resolving_via_slug_prefix("MYPROJ")).to contain_exactly(work_package)
end
end
describe "after_create registration", with_settings: { work_packages_identifier: "semantic" } do
it "assigns a sequence number" do
expect(work_package.reload.sequence_number).to eq(1)
@@ -66,6 +66,33 @@ RSpec.describe WorkPackageSemanticAlias do
end
end
describe ".for_slug_prefix" do
def alias_for(identifier)
described_class.create!(identifier:, work_package:)
end
it "matches identifiers of the exact form <slug>-<digits>" do
matching = alias_for("my-1")
alias_for("my-abc")
expect(described_class.for_slug_prefix("my")).to contain_exactly(matching)
end
it "does not over-match when another slug starts with the given slug plus a dash" do
matching = alias_for("my-2")
alias_for("my-project-42")
expect(described_class.for_slug_prefix("my")).to contain_exactly(matching)
end
it "matches case-sensitively" do
matching = alias_for("PROJ-1")
alias_for("proj-1")
expect(described_class.for_slug_prefix("PROJ")).to contain_exactly(matching)
end
end
describe WorkPackage do
describe "#semantic_aliases" do
let(:wp) { create(:work_package) }
@@ -0,0 +1,105 @@
# 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 ProjectIdentifiers::ReleaseReservedIdentifierService do
let!(:project) { create(:project) }
let!(:slug) { FriendlyId::Slug.create!(sluggable: project, slug: "old-id") }
subject(:service_call) { described_class.new(slug).call }
shared_examples "releases the slug and its aliases" do
# All fixtures share one project graph — deliberately NOT the slug-owning
# project: creating work packages there would auto-seed "old-id-*" aliases
# in semantic mode and collide with the explicit ones below.
let(:alias_work_package) { create(:work_package) }
let!(:matching_aliases) do
[create(:work_package_semantic_alias, identifier: "old-id-1", work_package: alias_work_package),
create(:work_package_semantic_alias, identifier: "old-id-2", work_package: alias_work_package)]
end
let!(:other_prefix_alias) do
create(:work_package_semantic_alias, identifier: "old-id-extra-7", work_package: alias_work_package)
end
let!(:case_differing_alias) do
create(:work_package_semantic_alias, identifier: "OLD-ID-3", work_package: alias_work_package)
end
let!(:stale_wp) do
create(:work_package, project: alias_work_package.project)
.tap { |wp| wp.update_columns(identifier: "old-id-9", sequence_number: 9) }
end
let!(:decoy_wp) do
create(:work_package, project: alias_work_package.project)
.tap { |wp| wp.update_columns(identifier: "old-id-extra-9", sequence_number: 8) }
end
it "destroys the slug and returns a successful ServiceResult" do
expect(service_call).to be_success
expect(FriendlyId::Slug.exists?(slug.id)).to be(false)
end
it "clears stale identifiers only from work packages carrying the released prefix" do
service_call
expect(stale_wp.reload).to have_attributes(identifier: nil, sequence_number: nil)
expect(decoy_wp.reload).to have_attributes(identifier: "old-id-extra-9", sequence_number: 8)
end
it "deletes only the exact-prefix aliases" do
expect { service_call }.to change(WorkPackageSemanticAlias, :count).by(-2)
remaining = WorkPackageSemanticAlias.pluck(:identifier)
expect(remaining).to include("old-id-extra-7", "OLD-ID-3")
expect(remaining).not_to include("old-id-1", "old-id-2")
end
context "when destroying the slug fails" do
before do
allow(slug).to receive(:destroy!).and_raise(ActiveRecord::RecordNotDestroyed)
end
it "rolls back the alias deletion and the work package identifier clearing" do
expect { service_call }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(WorkPackageSemanticAlias.where(identifier: %w[old-id-1 old-id-2]).count).to eq(2)
expect(stale_wp.reload).to have_attributes(identifier: "old-id-9", sequence_number: 9)
end
end
end
context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do
it_behaves_like "releases the slug and its aliases"
end
# Aliases left over from a previous semantic phase are cleaned up in classic
# mode too — otherwise they would shadow the alias rows of a new project
# claiming the identifier after a later re-conversion.
context "in classic mode", with_settings: { work_packages_identifier: "classic" } do
it_behaves_like "releases the slug and its aliases"
end
end