diff --git a/app/models/journal/caused_by_system_update.rb b/app/models/journal/caused_by_system_update.rb index 5cceaec9c6d..5ec71544f82 100644 --- a/app/models/journal/caused_by_system_update.rb +++ b/app/models/journal/caused_by_system_update.rb @@ -29,10 +29,9 @@ #++ # class Journal::CausedBySystemUpdate < CauseOfChange::Base - def initialize(feature:) - additional = { - "feature" => feature - } - super("system_update", additional) + def initialize(feature:, **additional_attributes) + system_update_attributes = + { "feature" => feature }.merge(additional_attributes.deep_stringify_keys) + super("system_update", system_update_attributes) end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 156f16a10c5..d667d4ec16b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3449,6 +3449,7 @@ en: totals_removed_from_childless_work_packages: >- Work and progress totals automatically removed for non-parent work packages with version update. This is a maintenance task and can be safely ignored. + sprint_migration: "Version '%{version_name}' has been converted to a Sprint." total_percent_complete_mode_changed_to_work_weighted_average: >- Child work packages without Work are ignored. total_percent_complete_mode_changed_to_simple_average: >- diff --git a/lib/open_project/journal_formatter/cause.rb b/lib/open_project/journal_formatter/cause.rb index 7170681b76b..3c708bafb5d 100644 --- a/lib/open_project/journal_formatter/cause.rb +++ b/lib/open_project/journal_formatter/cause.rb @@ -101,6 +101,8 @@ class OpenProject::JournalFormatter::Cause < JournalFormatter::Base { href: OpenProject::Static::Links.url_for(:blog_article_progress_changes) } when "totals_removed_from_childless_work_packages" { href: OpenProject::Static::Links.url_for(:release_notes_14_0_1) } + when "sprint_migration" + { version_name: cause["version_name"] } else {} end diff --git a/modules/backlogs/app/models/agile/sprint.rb b/modules/backlogs/app/models/agile/sprint.rb index cef4d7f6f34..d08bfde9f5e 100644 --- a/modules/backlogs/app/models/agile/sprint.rb +++ b/modules/backlogs/app/models/agile/sprint.rb @@ -54,13 +54,11 @@ module Agile default: "in_planning", validate: true - validates :name, presence: true - validates :project, presence: true - validates :start_date, presence: true - validates :finish_date, presence: true + validates :name, :project, presence: true + validates :start_date, :finish_date, presence: true, if: :active? validates :finish_date, comparison: { greater_than_or_equal_to: :start_date }, - if: :start_date? + if: -> { start_date? && finish_date? } validate :validate_only_one_active_sprint_per_project diff --git a/modules/backlogs/app/workers/backlogs/migrate_version_sprint_journals_job.rb b/modules/backlogs/app/workers/backlogs/migrate_version_sprint_journals_job.rb new file mode 100644 index 00000000000..0f05964c337 --- /dev/null +++ b/modules/backlogs/app/workers/backlogs/migrate_version_sprint_journals_job.rb @@ -0,0 +1,55 @@ +# 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. +#++ + +# Writes a journal entry on each work package that was associated with a sprint +# during the version-to-sprint migration. Runs asynchronously after the migration +# so that the migration itself does not block on journal creation. +module Backlogs + class MigrateVersionSprintJournalsJob < ApplicationJob + # wp_version_map: { work_package_id (Integer) => version_name (String) } + def perform(wp_version_map) + system_user = User.system + + work_packages = WorkPackage.where(id: wp_version_map.keys).index_by(&:id) + + Journal::NotificationConfiguration.with(false) do + wp_version_map.each do |wp_id_str, version_name| + wp_id = wp_id_str.to_i + work_package = work_packages[wp_id] + next unless work_package + + Journals::CreateService + .new(work_package, system_user) + .call(cause: Journal::CausedBySystemUpdate.new(feature: "sprint_migration", version_name:)) + end + end + end + end +end diff --git a/modules/backlogs/db/migrate/20260313163122_make_sprint_dates_nullable.rb b/modules/backlogs/db/migrate/20260313163122_make_sprint_dates_nullable.rb new file mode 100644 index 00000000000..1b270cbcbcb --- /dev/null +++ b/modules/backlogs/db/migrate/20260313163122_make_sprint_dates_nullable.rb @@ -0,0 +1,36 @@ +# 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 MakeSprintDatesNullable < ActiveRecord::Migration[8.0] + def change + change_column_null :sprints, :start_date, true + change_column_null :sprints, :finish_date, true + end +end diff --git a/modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints.rb b/modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints.rb new file mode 100644 index 00000000000..b20959f764f --- /dev/null +++ b/modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints.rb @@ -0,0 +1,76 @@ +# 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 MigrateVersionsToSprints < ActiveRecord::Migration[8.0] + def up + wp_version_map = {} + + sprint_versions_with_work_package_ids.each do |version, wp_ids| + sprint = create_sprint(version) + migrate_work_packages_to_sprint(sprint, wp_ids) + wp_ids.each { |wp_id| wp_version_map[wp_id.to_s] = sprint.name } + end + + Backlogs::MigrateVersionSprintJournalsJob.perform_later(wp_version_map) if wp_version_map.any? + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def sprint_versions_with_work_package_ids + # Load versions used as sprints including work package ids associated with the version. + # Since the same version can be used as a sprint in one project but not in another, + # the work package ids are filtered by projects where the version is used as a sprint. + Version.joins(:version_settings, :work_packages) + .where(version_settings: { display: VersionSetting::DISPLAY_LEFT }) + .where("work_packages.project_id = version_settings.project_id") + .includes(:project) + .group("versions.id") + .select("versions.*, array_agg(work_packages.id) AS wp_ids") + .map { |version| [version, version.wp_ids] } + end + + def create_sprint(version) + Agile::Sprint.create!( + name: version.name, + project: version.project, + status: version.status == "open" ? "in_planning" : "completed", + start_date: version.start_date, + finish_date: version.effective_date + ) + end + + def migrate_work_packages_to_sprint(sprint, wp_ids) + WorkPackage.where(id: wp_ids).update_all(sprint_id: sprint.id) + end +end diff --git a/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb b/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb index f542fac55b7..48b84dd1267 100644 --- a/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb +++ b/modules/backlogs/spec/contracts/sprints/create_contract_spec.rb @@ -89,13 +89,13 @@ RSpec.describe Sprints::CreateContract do context "when start_date is blank" do let(:sprint_start_date) { nil } - it_behaves_like "contract is invalid", start_date: :blank + it_behaves_like "contract is valid" end context "when finish_date is blank" do let(:sprint_finish_date) { nil } - it_behaves_like "contract is invalid", finish_date: %i[blank blank] + it_behaves_like "contract is valid" end context "when finish_date is before start_date" do @@ -105,6 +105,29 @@ RSpec.describe Sprints::CreateContract do it_behaves_like "contract is invalid", finish_date: %i[greater_than_or_equal_to] end + context "when the sprint is active" do + let(:sprint_status) { "active" } + + context "when start_date is blank" do + let(:sprint_start_date) { nil } + + it_behaves_like "contract is invalid", start_date: :blank + end + + context "when finish_date is blank" do + let(:sprint_finish_date) { nil } + + it_behaves_like "contract is invalid", finish_date: :blank + end + + context "when finish_date is before start_date" do + let(:sprint_start_date) { Time.zone.today } + let(:sprint_finish_date) { Time.zone.today - 1.day } + + it_behaves_like "contract is invalid", finish_date: %i[greater_than_or_equal_to] + end + end + context "when user is admin without project permission" do let(:user) { build_stubbed(:admin) } let(:permissions) { [] } diff --git a/modules/backlogs/spec/features/sprints/create_spec.rb b/modules/backlogs/spec/features/sprints/create_spec.rb index 3bc70e498be..f3e484ee720 100644 --- a/modules/backlogs/spec/features/sprints/create_spec.rb +++ b/modules/backlogs/spec/features/sprints/create_spec.rb @@ -121,20 +121,6 @@ RSpec.describe "Create", :js do describe "validations" do let(:too_early_finish_date) { start_date - 1.day } - it "validates required fields are present" do - backlogs_page.open_create_sprint_dialog - - within_dialog "New sprint" do - page.fill_in "Sprint name", with: "" - - click_on "Create" - - expect(page).to have_field "Sprint name", validation_error: "can't be blank" - expect(page).to have_field "Start date", validation_error: "can't be blank" - expect(page).to have_field "Finish date", validation_error: "can't be blank" - end - end - it "validates finish date is not before start date" do backlogs_page.open_create_sprint_dialog diff --git a/modules/backlogs/spec/features/sprints/edit_spec.rb b/modules/backlogs/spec/features/sprints/edit_spec.rb index 8e49b34ddbd..442f6262c68 100644 --- a/modules/backlogs/spec/features/sprints/edit_spec.rb +++ b/modules/backlogs/spec/features/sprints/edit_spec.rb @@ -162,6 +162,29 @@ RSpec.describe "Edit", :js do end end end + + describe "validations" do + context "when sprint status is active" do + before { first_sprint.update!(status: "active") } + + it "validates required fields are present" do + backlogs_page.click_in_sprint_menu(first_sprint, "Edit sprint") + backlogs_page.expect_sprint_dialog + + within_dialog "Edit sprint" do + page.fill_in "Sprint name", with: "" + page.fill_in "Start date", with: "" + page.fill_in "Finish date", with: "" + + page.click_button "Save" + + expect(page).to have_field "Sprint name", validation_error: "can't be blank" + expect(page).to have_field "Start date", validation_error: "can't be blank" + expect(page).to have_field "Finish date", validation_error: "can't be blank" + end + end + end + end end end diff --git a/modules/backlogs/spec/migrations/migrate_versions_to_sprints_spec.rb b/modules/backlogs/spec/migrations/migrate_versions_to_sprints_spec.rb new file mode 100644 index 00000000000..ca7085207ff --- /dev/null +++ b/modules/backlogs/spec/migrations/migrate_versions_to_sprints_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require Rails.root.join("modules/backlogs/db/migrate/20260313164539_migrate_versions_to_sprints") + +RSpec.describe MigrateVersionsToSprints, type: :model do + subject(:migrate) { ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } } + + let(:project) { create(:project) } + let(:start_date) { nil } + let(:effective_date) { nil } + let(:status) { "open" } + let(:version_type) { :sprint } + let!(:version) do + create(:version, project:, name: "Test Sprint", start_date:, effective_date:, status:) + end + let!(:wp1) { create(:work_package, version:, project:) } + + def use_version(as:, version: self.version, project: self.project) + display = as == :sprint ? VersionSetting::DISPLAY_LEFT : VersionSetting::DISPLAY_RIGHT + create(:version_setting, version:, project:, display:) + end + + before { use_version(as: version_type) } + + describe "qualification criteria" do + context "when all criteria are met (used as sprint and work package present)" do + let(:start_date) { Date.new(2026, 1, 1) } + let(:effective_date) { Date.new(2026, 1, 14) } + + it "creates one sprint" do + expect { migrate }.to change(Agile::Sprint, :count).by(1) + end + + it "copies name, start_date and finish_date" do + migrate + sprint = Agile::Sprint.last + expect(sprint.name).to eq("Test Sprint") + expect(sprint.start_date).to eq(Date.new(2026, 1, 1)) + expect(sprint.finish_date).to eq(Date.new(2026, 1, 14)) + end + end + + context "when version is used as a backlog" do + let(:version_type) { :backlog } + + it "does not create a sprint" do + expect { migrate }.not_to change(Agile::Sprint, :count) + end + end + + context "when no work packages are associated with the version" do + let!(:wp1) { nil } + + it "does not create a sprint" do + expect { migrate }.not_to change(Agile::Sprint, :count) + end + end + end + + describe "date handling" do + context "when both start_date and effective_date are null" do + it "creates a sprint with nil dates" do + expect { migrate }.to change(Agile::Sprint, :count).by(1) + sprint = Agile::Sprint.last + expect(sprint.start_date).to be_nil + expect(sprint.finish_date).to be_nil + end + end + + context "when only effective_date is set" do + let(:effective_date) { Date.new(2026, 2, 28) } + + it "sets effective_date for finish_date" do + migrate + sprint = Agile::Sprint.last + expect(sprint.start_date).to be_nil + expect(sprint.finish_date).to eq(Date.new(2026, 2, 28)) + end + end + + context "when only start_date is set" do + let(:start_date) { Date.new(2026, 2, 1) } + + it "sets start_date for start_date" do + migrate + sprint = Agile::Sprint.last + expect(sprint.start_date).to eq(Date.new(2026, 2, 1)) + expect(sprint.finish_date).to be_nil + end + end + end + + describe "status mapping" do + context "when version status is open" do + let(:status) { "open" } + + it "creates sprint with in_planning status" do + migrate + expect(Agile::Sprint.last.status).to eq("in_planning") + end + end + + context "when version status is locked" do + let(:status) { "locked" } + + it "creates sprint with completed status" do + migrate + expect(Agile::Sprint.last.status).to eq("completed") + end + end + + context "when version status is closed" do + let(:status) { "closed" } + + it "creates sprint with completed status" do + migrate + expect(Agile::Sprint.last.status).to eq("completed") + end + end + end + + describe "work package association" do + let!(:wp2) { create(:work_package, version:, project:) } + + it "sets sprint_id on all associated work packages" do + migrate + sprint = Agile::Sprint.last + expect(wp1.reload.sprint_id).to eq(sprint.id) + expect(wp2.reload.sprint_id).to eq(sprint.id) + end + + it "keeps the version_id on associated work packages" do + migrate + expect(wp1.reload.version_id).to eq(version.id) + expect(wp2.reload.version_id).to eq(version.id) + end + + context "with multiple versions" do + let!(:version2) { create(:version, project:, status: "closed") } + let!(:wp3) { create(:work_package, version: version2, project:) } + + before { use_version(as: :sprint, version: version2) } + + it "assigns work packages to their respective sprints" do + migrate + sprints = Agile::Sprint.all.index_by(&:name) + expect(wp1.reload.sprint_id).to eq(sprints[version.name].id) + expect(wp2.reload.sprint_id).to eq(sprints[version.name].id) + expect(wp3.reload.sprint_id).to eq(sprints[version2.name].id) + end + end + + context "when the version is shared with another project that displays it as non-sprint" do + let(:other_project) { create(:project) } + let!(:wp_in_other_project) { create(:work_package, version:, project: other_project) } + + before { use_version(as: :backlog, project: other_project) } + + it "only assigns work packages from the sprint project" do + migrate + sprint = Agile::Sprint.last + expect(wp1.reload.sprint_id).to eq(sprint.id) + expect(wp2.reload.sprint_id).to eq(sprint.id) + expect(wp_in_other_project.reload.sprint_id).to be_nil + end + end + end + + describe "journal job" do + it "enqueues MigrateVersionSprintJournalsJob with the work package mapping" do + allow(Backlogs::MigrateVersionSprintJournalsJob).to receive(:perform_later) + + migrate + + expect(Backlogs::MigrateVersionSprintJournalsJob) + .to have_received(:perform_later) + .with({ wp1.id.to_s => "Test Sprint" }) + end + end +end diff --git a/modules/backlogs/spec/models/agile/sprint_spec.rb b/modules/backlogs/spec/models/agile/sprint_spec.rb index 5ce79972dea..2063cf2c23e 100644 --- a/modules/backlogs/spec/models/agile/sprint_spec.rb +++ b/modules/backlogs/spec/models/agile/sprint_spec.rb @@ -32,51 +32,66 @@ require "spec_helper" RSpec.describe Agile::Sprint do let(:project) { create(:project) } + let(:sprint_status) { "in_planning" } subject(:sprint) do described_class.new(name: "Sprint 1", project:, start_date: Time.zone.today, - finish_date: Time.zone.today + 14.days) + finish_date: Time.zone.today + 14.days, + status: sprint_status) end describe "validations" do it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_presence_of(:start_date) } - it { is_expected.to validate_presence_of(:finish_date) } it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_inclusion_of(:status).in_array(described_class.statuses.keys) } - it "validates finish_date is after or equal to start_date" do - sprint.finish_date = sprint.start_date - 1.day - expect(sprint).not_to be_valid - expect(sprint.errors[:finish_date]).to include(/must be greater than or equal to/) - end - - it "does not validate finish_date comparison when start_date is nil" do - sprint.start_date = nil - sprint.finish_date = Time.zone.today - expect(sprint).not_to be_valid - expect(sprint.errors[:start_date]).to be_present - expect(sprint.errors[:finish_date]).not_to include(/must be greater than or equal to/) - end - - it "still validates finish_date presence even when start_date is nil" do + it "allows nil start and finish dates" do sprint.start_date = nil sprint.finish_date = nil - expect(sprint).not_to be_valid - expect(sprint.errors[:finish_date]).to be_present + expect(sprint).to be_valid + end + + it "allows a nil finish date when start date is present" do + sprint.start_date = Time.zone.today + sprint.finish_date = nil + expect(sprint).to be_valid end context "with active sprint validation" do + let(:sprint_status) { "active" } + + it { is_expected.to validate_presence_of(:start_date) } + it { is_expected.to validate_presence_of(:finish_date) } + + it "validates finish_date is after or equal to start_date" do + sprint.finish_date = sprint.start_date - 1.day + expect(sprint).not_to be_valid + expect(sprint.errors[:finish_date]).to include(/must be greater than or equal to/) + end + + it "does not validate finish_date comparison when start_date is nil" do + sprint.start_date = nil + sprint.finish_date = Time.zone.today + expect(sprint).not_to be_valid + expect(sprint.errors[:start_date]).to be_present + expect(sprint.errors[:finish_date]).not_to include(/must be greater than or equal to/) + end + + it "still validates finish_date presence even when start_date is nil" do + sprint.start_date = nil + sprint.finish_date = nil + expect(sprint).not_to be_valid + expect(sprint.errors[:finish_date]).to be_present + end + it "allows one active sprint per project" do - sprint.status = "active" expect(sprint).to be_valid end it "prevents multiple active sprints in the same project" do create(:agile_sprint, project:, status: "active") - sprint.status = "active" expect(sprint).not_to be_valid expect(sprint.errors[:status]).to include("only one active sprint is allowed per project.") end @@ -84,12 +99,10 @@ RSpec.describe Agile::Sprint do it "allows multiple active sprints in different projects" do other_project = create(:project) create(:agile_sprint, project: other_project, status: "active") - sprint.status = "active" expect(sprint).to be_valid end it "allows updating an existing active sprint" do - sprint.status = "active" sprint.save! sprint.name = "Updated Sprint" expect(sprint).to be_valid diff --git a/modules/backlogs/spec/workers/backlogs/migrate_version_sprint_journals_job_spec.rb b/modules/backlogs/spec/workers/backlogs/migrate_version_sprint_journals_job_spec.rb new file mode 100644 index 00000000000..e4a3543b0e6 --- /dev/null +++ b/modules/backlogs/spec/workers/backlogs/migrate_version_sprint_journals_job_spec.rb @@ -0,0 +1,94 @@ +# 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 Backlogs::MigrateVersionSprintJournalsJob, type: :model do + let(:project) { create(:project) } + let(:wp1) { create(:work_package, project:) } + let(:wp2) { create(:work_package, project:) } + + subject(:perform) { described_class.new.perform(wp_version_map) } + + describe "#perform" do + context "with multiple work packages" do + let(:wp_version_map) do + { + wp1.id.to_s => "Sprint A", + wp2.id.to_s => "Sprint B" + } + end + + it "creates a journal entry for each work package authored by the system user" do + perform + expect(wp1.reload.last_journal.user).to eq(User.system) + expect(wp2.reload.last_journal.user).to eq(User.system) + end + + it "sets the cause type to system_update" do + perform + expect(wp1.reload.last_journal.cause_type).to eq("system_update") + expect(wp2.reload.last_journal.cause_type).to eq("system_update") + end + + it "sets the cause feature to sprint_migration" do + perform + expect(wp1.reload.last_journal.cause_feature).to eq("sprint_migration") + expect(wp2.reload.last_journal.cause_feature).to eq("sprint_migration") + end + + it "stores the originating version name in the cause" do + perform + expect(wp1.reload.last_journal.cause["version_name"]).to eq("Sprint A") + expect(wp2.reload.last_journal.cause["version_name"]).to eq("Sprint B") + end + + it "suppresses journal notifications" do + allow(Journal::NotificationConfiguration).to receive(:with).and_call_original + perform + expect(Journal::NotificationConfiguration).to have_received(:with).with(false) + end + end + + context "when the map contains an id that no longer exists" do + let(:wp_version_map) do + { + "0" => "Ghost Sprint", + wp1.id.to_s => "Sprint A" + } + end + + it "skips the missing id and still journals the existing work package" do + expect { perform }.not_to raise_error + expect(wp1.reload.last_journal.cause["version_name"]).to eq("Sprint A") + end + end + end +end diff --git a/spec/lib/open_project/journal_formatter/cause_spec.rb b/spec/lib/open_project/journal_formatter/cause_spec.rb index 73d42e972d5..b77d9d4b26a 100644 --- a/spec/lib/open_project/journal_formatter/cause_spec.rb +++ b/spec/lib/open_project/journal_formatter/cause_spec.rb @@ -550,6 +550,30 @@ RSpec.describe OpenProject::JournalFormatter::Cause do end end + context "when the change was caused by a sprint migration" do + subject(:cause) do + { + "type" => "system_update", + "feature" => "sprint_migration", + "version_name" => "Sprint 1" + } + end + + it do + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.system_update')} " \ + "#{I18n.t('journals.cause_descriptions.system_update.sprint_migration', version_name: 'Sprint 1')}" + ) + end + + it do + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.system_update')} " \ + "#{I18n.t('journals.cause_descriptions.system_update.sprint_migration', version_name: 'Sprint 1')}" + ) + end + end + context "when the change was caused by an import" do subject(:cause) do {