Files

971 lines
28 KiB
Ruby

# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
RSpec.describe WorkPackage do
shared_let(:type) { create(:type_standard) }
shared_let(:project) { create(:project, types: [type]) }
shared_let(:project_archived) { create(:project, :archived) }
shared_let(:status) { create(:status) }
shared_let(:priority) { create(:priority) }
shared_let(:user1) { create(:user) }
before_all do
set_factory_default(:user, user1)
set_factory_default(:project, project)
set_factory_default(:project_with_types, project)
end
let(:stub_work_package) { build_stubbed(:work_package) }
let(:stub_version) { build_stubbed(:version) }
let(:stub_project) { build_stubbed(:project) }
let(:user) { user1 }
let(:work_package) do
described_class.new.tap do |w|
w.attributes = { project_id: project.id,
type_id: type.id,
author_id: user.id,
status_id: status.id,
priority:,
subject: "test_create",
description: "WorkPackage#create",
estimated_hours: "1h30" }
end
end
describe "associations" do
subject { work_package }
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:type) }
it { is_expected.to belong_to(:status) }
it { is_expected.to belong_to(:author) }
it { is_expected.to belong_to(:assigned_to).class_name("Principal").optional }
it { is_expected.to belong_to(:responsible).class_name("Principal").optional }
it { is_expected.to belong_to(:version).optional }
it { is_expected.to belong_to(:project_phase_definition).class_name("Project::PhaseDefinition").optional }
it { is_expected.to belong_to(:priority).class_name("IssuePriority") }
it { is_expected.to belong_to(:category).optional }
it { is_expected.to have_many(:time_entries).dependent(:delete_all) }
it { is_expected.to have_many(:file_links).dependent(:delete_all).class_name("Storages::FileLink") }
it { is_expected.to have_many(:storages).through(:project) }
it { is_expected.to have_and_belong_to_many(:changesets) }
it { is_expected.to have_and_belong_to_many(:github_pull_requests) }
it { is_expected.to have_many(:members).dependent(:destroy) }
it { is_expected.to have_many(:member_principals).through(:members).class_name("Principal").source(:principal) }
it { is_expected.to have_many(:meeting_agenda_items) }
it { is_expected.to have_many(:meetings).through(:meeting_agenda_items).source(:meeting) }
end
describe ".new" do
describe "type" do
let(:type2) { create(:type) }
before do
project.types << type2
end
context "when no project chosen" do
it "has no type set if no project was chosen" do
expect(described_class.new.type)
.to be_nil
end
end
context "when project chosen" do
it "has the provided type if one is provided" do
expect(described_class.new(project:, type: type2).type)
.to eql type2
end
end
end
end
describe "create" do
describe "#save" do
subject { work_package.save }
it { is_expected.to be_truthy }
end
describe "#estimated_hours" do
before do
work_package.save!
work_package.reload
end
subject { work_package.estimated_hours }
it { is_expected.to eq(1.5) }
end
describe "minimal" do
let(:work_package_minimal) do
described_class.new.tap do |w|
w.attributes = { project_id: project.id,
type_id: type.id,
author_id: user.id,
status_id: status.id,
priority:,
subject: "test_create" }
end
end
describe "save" do
subject { work_package_minimal.save }
it { is_expected.to be_truthy }
end
describe "description" do
before do
work_package_minimal.save!
work_package_minimal.reload
end
subject { work_package_minimal.description }
it { is_expected.to be_nil }
end
end
describe "#assigned_to" do
describe "group_assignment" do
let(:group) { create(:group) }
subject do
create(:work_package,
assigned_to: group).assigned_to
end
it { is_expected.to eq(group) }
end
end
end
describe "#hide_attachments?" do
subject { work_package.hide_attachments? }
context "when project is present" do
context "when project#deactivate_work_package_attachments is true" do
before { work_package.project.deactivate_work_package_attachments = true }
it { is_expected.to be_truthy }
end
context "when project#deactivate_work_package_attachments is false" do
before { work_package.project.deactivate_work_package_attachments = false }
it { is_expected.to be_falsey }
end
end
context "when project is absent" do
before { work_package.project = nil }
it { is_expected.to be_falsey }
end
end
describe "#category" do
let(:user2) { create(:user, member_with_permissions: { project => %i[view_work_packages edit_work_packages] }) }
let(:category) do
create(:category,
project:,
assigned_to: user2)
end
before do
work_package.attributes = { category_id: category.id }
work_package.save!
end
subject { work_package.assigned_to }
it { is_expected.to eq(category.assigned_to) }
end
describe "responsible" do
let(:group) { create(:group) }
let!(:member) do
create(:member,
principal: group,
project: work_package.project,
roles: [create(:project_role)])
end
context "with group assigned" do
before { work_package.responsible = group }
it "is valid" do
expect(work_package).to be_valid
end
end
end
describe "#assignable_versions" do
let(:stub_version2) { build_stubbed(:version) }
def stub_shared_versions(version = nil)
versions = version ? [version] : []
allow(stub_work_package.project).to receive(:assignable_versions).and_return(versions)
end
it "returns all the project's shared versions" do
stub_shared_versions(stub_version)
expect(stub_work_package.assignable_versions).to eq([stub_version])
end
it "returns the former version if the version changed" do
stub_shared_versions
stub_work_package.version = stub_version2
allow(stub_work_package).to receive_messages(version_id_changed?: true, version_id_was: stub_version.id)
allow(Version).to receive(:find_by).with(id: stub_version.id).and_return(stub_version)
expect(stub_work_package.assignable_versions).to eq([stub_version])
end
it "returns the current version if the version did not change" do
stub_shared_versions
stub_work_package.version = stub_version
allow(stub_work_package).to receive(:version_id_changed?).and_return false
expect(stub_work_package.assignable_versions).to eq([stub_version])
end
context "with many versions" do
let!(:work_package) do
wp = create(:work_package,
project:,
version: version_current)
# remove changes to version factored into
# assignable_versions calculation
wp.reload
wp
end
let!(:version_current) do
create(:version,
status: "closed",
project:)
end
let!(:version_open) do
create(:version,
status: "open",
project:)
end
let!(:version_locked) do
create(:version,
status: "locked",
project:)
end
let!(:version_closed) do
create(:version,
status: "closed",
project:)
end
let!(:version_other_project) do
create(:version,
status: "open",
project: create(:project))
end
it "returns all open versions of the project" do
expect(work_package.assignable_versions)
.to contain_exactly(version_current, version_open)
end
end
end
describe "#destroy" do
let!(:time_entry1) do
create(:time_entry,
project:,
entity: work_package)
end
let!(:time_entry2) do
create(:time_entry,
project:,
entity: work_package)
end
before do
work_package.destroy
end
describe "work package" do
subject { described_class.find_by(id: work_package.id) }
it { is_expected.to be_nil }
end
describe "time entries" do
subject { TimeEntry.find_by(work_package_id: work_package.id) }
it { is_expected.to be_nil }
end
end
it_behaves_like "creates an audit trail on destroy" do
subject { create(:work_package) }
end
describe "#done_ratio" do
shared_let(:status_new) do
create(:status,
name: "New",
is_default: true,
is_closed: false,
default_done_ratio: 50)
end
shared_let(:status_assigned) do
create(:status,
name: "Assigned",
is_default: true,
is_closed: false,
default_done_ratio: 0)
end
shared_let(:work_package_new) do
create(:work_package,
status: status_new)
end
shared_let(:work_package_assigned) do
create(:work_package,
project: work_package_new.project,
status: status_assigned,
done_ratio: 30)
end
it "allows empty value" do
work_package.done_ratio = ""
expect(work_package).to be_valid
expect(work_package.done_ratio).to be_nil
end
it "allows blank values" do
work_package.done_ratio = " "
expect(work_package).to be_valid
expect(work_package.done_ratio).to be_nil
end
it "allows nil value" do
work_package.done_ratio = nil
expect(work_package).to be_valid
expect(work_package.done_ratio).to be_nil
end
it "allows values between 0 and 100" do
work_package.done_ratio = 0
expect(work_package).to be_valid
work_package.done_ratio = 34
expect(work_package).to be_valid
work_package.done_ratio = 99
expect(work_package).to be_valid
work_package.done_ratio = "1"
expect(work_package).to be_valid
work_package.done_ratio = "100"
expect(work_package).to be_valid
end
it "disallows values outside of the 0-100 range" do
work_package.done_ratio = -1
expect(work_package).not_to be_valid
work_package.done_ratio = "-1%"
expect(work_package.done_ratio).to eq(-1)
expect(work_package).not_to be_valid
work_package.done_ratio = 101.0
expect(work_package.done_ratio).to eq(101)
expect(work_package).not_to be_valid
end
it "allows floats and truncates them to integer" do
work_package.done_ratio = 1.7
expect(work_package).to be_valid
expect(work_package.done_ratio).to eq(1)
work_package.done_ratio = "1.7"
expect(work_package).to be_valid
expect(work_package.done_ratio).to eq(1)
end
it "allows percentage like '50%'" do
work_package.done_ratio = "50%"
expect(work_package).to be_valid
expect(work_package.done_ratio).to eq(50)
end
it "disallows string values, that are not valid percentage values" do
work_package.done_ratio = "abc"
expect(work_package).not_to be_valid
end
describe "#value" do
context "for work-based mode",
with_settings: { work_package_done_ratio: "field" } do
it "returns the value from work package field" do
expect(work_package_new.done_ratio).to be_nil
expect(work_package_assigned.done_ratio).to eq(30)
end
end
context "for status-based mode",
with_settings: { work_package_done_ratio: "status" } do
it "uses the % Complete value from the work package status" do
expect(work_package_new.done_ratio).to eq(status_new.default_done_ratio)
expect(work_package_assigned.done_ratio).to eq(status_assigned.default_done_ratio)
end
end
end
describe "#update_done_ratio_from_status" do
context "for work-based mode",
with_settings: { work_package_done_ratio: "field" } do
it "does not update the done ratio" do
expect { work_package_new.update_done_ratio_from_status }
.not_to change { work_package_new[:done_ratio] }
expect { work_package_assigned.update_done_ratio_from_status }
.not_to change { work_package_assigned[:done_ratio] }
end
end
context "for status-based mode",
with_settings: { work_package_done_ratio: "status" } do
it "updates the done ratio without saving it" do
expect { work_package_new.update_done_ratio_from_status }
.to change { work_package_new[:done_ratio] }
.from(nil).to(50)
expect { work_package_assigned.update_done_ratio_from_status }
.to change { work_package_assigned[:done_ratio] }
.from(30).to(0)
expect(work_package_new).to have_changes_to_save
end
end
end
end
describe "#group_by" do
shared_let(:type2) { create(:type) }
shared_let(:priority2) { create(:priority) }
shared_let(:project) { create(:project, types: [type, type2]) }
shared_let(:version1) { create(:version, project:) }
shared_let(:version2) { create(:version, project:) }
shared_let(:category1) { create(:category, project:) }
shared_let(:category2) { create(:category, project:) }
shared_let(:user2) { create(:user) }
shared_let(:work_package1) do
create(:work_package,
author: user1,
assigned_to: user1,
responsible: user1,
project:,
type:,
priority:,
version: version1,
category: category1)
end
shared_let(:work_package2) do
create(:work_package,
author: user2,
assigned_to: user2,
responsible: user2,
project:,
type: type2,
priority: priority2,
version: version2,
category: category2)
end
shared_examples_for "group by" do
describe "size" do
subject { groups.size }
it { is_expected.to eq(2) }
end
describe "total" do
subject { groups.inject(0) { |sum, group| sum + group["total"].to_i } }
it { is_expected.to eq(2) }
end
end
describe "by type" do
let(:groups) { described_class.by_type(project) }
it_behaves_like "group by"
end
describe "by version" do
let(:groups) { described_class.by_version(project) }
it_behaves_like "group by"
end
describe "by priority" do
let(:groups) { described_class.by_priority(project) }
it_behaves_like "group by"
end
describe "by category" do
let(:groups) { described_class.by_category(project) }
it_behaves_like "group by"
end
describe "by assigned to" do
let(:groups) { described_class.by_assigned_to(project) }
it_behaves_like "group by"
end
describe "by responsible" do
let(:groups) { described_class.by_responsible(project) }
it_behaves_like "group by"
end
describe "by author" do
let(:groups) { described_class.by_author(project) }
it_behaves_like "group by"
end
describe "by project" do
shared_let(:project2) { create(:project, parent: project) }
shared_let(:work_package3) { create(:work_package, project: project2) }
let(:groups) { described_class.by_author(project) }
it_behaves_like "group by"
end
end
describe "#recently_updated" do
let!(:work_package1) { create(:work_package) }
let!(:work_package2) { create(:work_package) }
before do
without_timestamping do
work_package1.update_column(:updated_at, 1.minute.ago)
end
end
describe "limit" do
subject { described_class.recently_updated.limit(1).first }
it { is_expected.to eq(work_package2) }
end
end
describe "#on_active_project" do
shared_let(:work_package) { create(:work_package, project:) }
subject { described_class.on_active_project.length }
context "with one work package in active projects" do
it { is_expected.to eq(1) }
context "and one work package in archived projects" do
shared_let(:work_package_in_archived_project) do
create(:work_package, project: project_archived)
end
it { is_expected.to eq(1) }
end
end
end
describe "#with_author" do
shared_let(:work_package) { create(:work_package, project:, author: user1) }
subject { described_class.with_author(user1).length }
context "with one work package in active projects" do
it { is_expected.to eq(1) }
context "and one work package in archived projects" do
shared_let(:work_package_in_archived_project) do
create(:work_package, project: project_archived, author: user1)
end
it { is_expected.to eq(2) }
end
end
end
describe "#add_time_entry" do
it "returns a new time entry" do
expect(stub_work_package.add_time_entry).to be_a TimeEntry
end
it "has already the project assigned" do
stub_work_package.project = stub_project
expect(stub_work_package.add_time_entry.project).to eq(stub_project)
end
it "has already the work_package assigned" do
expect(stub_work_package.add_time_entry.entity).to eq(stub_work_package)
end
it "returns an unsaved entry" do
expect(stub_work_package.add_time_entry).to be_new_record
end
end
describe ".allowed_target_project_on_move" do
let(:permissions) { [:move_work_packages] }
let(:user) do
create(:user, member_with_permissions: { project => permissions })
end
context "when having the move_work_packages permission" do
it "returns the project" do
expect(described_class.allowed_target_projects_on_move(user))
.to contain_exactly(project)
end
end
context "when lacking the move_work_packages permission" do
let(:permissions) { [] }
it "does not return the project" do
expect(described_class.allowed_target_projects_on_move(user))
.to be_empty
end
end
end
describe ".allowed_target_project_on_create" do
let(:permissions) { [:add_work_packages] }
let(:user) do
create(:user, member_with_permissions: { project => permissions })
end
context "when having the add_work_packages permission" do
it "returns the project" do
expect(described_class.allowed_target_projects_on_create(user))
.to contain_exactly(project)
end
end
context "when lacking the add_work_packages permission" do
let(:permissions) { [] }
it "does not return the project" do
expect(described_class.allowed_target_projects_on_create(user))
.to be_empty
end
end
end
describe "#duration" do
context "when not setting a value" do
it "is nil" do
expect(work_package).to have_attributes(duration: nil)
end
end
context "when setting the value" do
before do
work_package.duration = 5
end
it "is the value" do
expect(work_package).to have_attributes(duration: 5)
end
end
end
describe "#duration_in_hours" do
context "when not setting duration" do
it "is nil" do
expect(work_package).to have_attributes(duration_in_hours: nil)
end
end
context "when setting duration value" do
before do
work_package.duration = 5
end
it "is the value" do
expect(work_package).to have_attributes(duration_in_hours: 120)
end
end
end
describe "changed_since" do
shared_let(:work_package) do
Timecop.travel(5.hours.ago) do
create(:work_package, project:)
end
end
subject { described_class.changed_since(since) }
describe "null" do
let(:since) { nil }
it { expect(subject).to contain_exactly(work_package) }
end
describe "now" do
let(:since) { DateTime.now }
it { expect(subject).to be_empty }
end
describe "work package update" do
let(:since) { work_package.reload.updated_at }
it { expect(subject).to contain_exactly(work_package) }
end
end
describe "#ignore_non_working_days" do
context "for a new record" do
it "is false" do
expect(described_class.new.ignore_non_working_days)
.to be false
end
end
end
context "when destroying with agenda items" do
shared_let(:work_package) do
create(:work_package, project:, type:, status:, priority:)
end
shared_let(:meeting_agenda_items) { create_list(:meeting_agenda_item, 3, work_package:) }
shared_let(:other_agenda_item) { create(:meeting_agenda_item, work_package_id: create(:work_package).id) }
shared_let(:other_meeting) { other_agenda_item.meeting }
let(:latest_journals) do
Journal
.select("DISTINCT ON (journable_id) *")
.where(journable_type: "Meeting", journable_id: meeting_agenda_items.pluck(:meeting_id))
.order("journable_id, updated_at DESC")
end
subject { work_package.destroy }
before do
work_package.save
Meeting.find_each(&:save_journals)
end
it "dissociates the agenda items" do
expect { subject }
.to change { MeetingAgendaItem.find(meeting_agenda_items).pluck(:work_package_id) }
.from(Array.new(3, work_package.id))
.to(Array.new(3, nil))
end
it "does not affect other agenda items" do
expect { subject }.not_to change(other_agenda_item, :reload)
end
it "updates the agenda item journal" do
expect { subject }
.to change {
Journal::MeetingAgendaItemJournal
.where(agenda_item: meeting_agenda_items)
.pluck(:work_package_id)
}
.from(Array.new(3, work_package.id))
.to(Array.new(3, nil))
end
it "does not affect the agenda item journal" do
expect { subject }
.not_to change {
Journal::MeetingAgendaItemJournal
.find_by(agenda_item: other_agenda_item)
.work_package_id
}
end
end
describe "#remaining_hours" do
it "allows empty value" do
work_package.remaining_hours = ""
expect(work_package).to be_valid
expect(work_package.remaining_hours).to be_nil
end
it "allows blank values" do
work_package.remaining_hours = " "
expect(work_package).to be_valid
expect(work_package.remaining_hours).to be_nil
end
it "allows nil value" do
work_package.remaining_hours = nil
expect(work_package).to be_valid
expect(work_package.remaining_hours).to be_nil
end
it "allows values greater than or equal to 0" do
work_package.remaining_hours = "0"
expect(work_package).to be_valid
work_package.remaining_hours = "1"
expect(work_package).to be_valid
end
it "disallows negative values" do
work_package.remaining_hours = "-1"
expect(work_package).not_to be_valid
work_package.remaining_hours = "-1h"
expect(work_package.remaining_hours).to eq(-1)
expect(work_package).not_to be_valid
end
it "disallows string values, that are not numbers" do
work_package.remaining_hours = "abc"
expect(work_package).not_to be_valid
end
it "allows non-integers" do
work_package.remaining_hours = "1.3"
expect(work_package).to be_valid
end
it "allows hours like '1h06'" do
work_package.remaining_hours = "1h06"
expect(work_package).to be_valid
expect(work_package.remaining_hours).to eq(1.1)
end
it "allows hours like '1h 24m'" do
work_package.remaining_hours = "1h 24m"
expect(work_package).to be_valid
expect(work_package.remaining_hours).to eq(1.4)
end
it "allows hours like '3d 1.5h 30m'" do
work_package.remaining_hours = "3d 1h 30m"
expect(work_package).to be_valid
expect(work_package.remaining_hours).to eq((3 * 8) + 1.5)
end
end
describe "#project_phase" do
let(:project_phase) { build_stubbed(:project_phase, definition: project_phase_definition) }
let(:project_phase_definition) { build_stubbed(:project_phase_definition) }
let(:project) { build_stubbed(:project, phases: [project_phase]) }
let(:work_package) do
build_stubbed(:work_package,
project:,
project_phase_definition: project_phase_definition)
end
describe "when the project phase exists in the project" do
it "returns the project phase definition" do
expect(work_package.project_phase).to eq(project_phase)
end
end
describe "when the project phase does not exist in the project (e.g. moved into the project)" do
# There is now only another project phase active in the project
let(:project_phase) { build_stubbed(:project_phase, definition: build_stubbed(:project_phase_definition)) }
it "returns nil" do
expect(work_package.project_phase).to be_nil
end
end
end
describe "#infoline" do
let(:infoline_type) { create(:type, name: "Task") }
let(:infoline_work_package) do
create(:work_package, subject: "Hello world", project: infoline_project, type: infoline_type)
end
context "when semantic mode is active",
with_settings: { work_packages_identifier: "semantic" } do
let(:infoline_project) { create(:project, identifier: "MYPROJ") }
before { infoline_work_package }
it "renders the semantic identifier without a hash prefix" do
expect(infoline_work_package.reload.infoline).to eq("Task: Hello world (MYPROJ-1)")
end
end
context "when semantic mode is not active",
with_settings: { work_packages_identifier: "classic" } do
let(:infoline_project) { create(:project) }
it "renders the hash-prefixed numeric id" do
expect(infoline_work_package.infoline).to eq("Task: Hello world (##{infoline_work_package.id})")
end
end
end
describe "#to_s" do
let(:task_type) { create(:type_task) }
context "in classic mode",
with_settings: { work_packages_identifier: "classic" } do
let(:work_package) { create(:work_package, project:, type: task_type, subject: "Hello world") }
it "renders the numeric id with a `#` prefix" do
expect(work_package.to_s).to eq("Task ##{work_package.id}: Hello world")
end
end
context "in semantic mode",
with_settings: { work_packages_identifier: "semantic" } do
let(:project) { create(:project, identifier: "MACROPROJ", types: [task_type]) }
let(:work_package) { create(:work_package, project:, type: task_type, subject: "Hello world") }
it "renders the semantic identifier without a `#` prefix" do
wp = work_package.reload
expect(wp.to_s).to eq("Task #{wp.display_id}: Hello world")
expect(wp.to_s).not_to include("##{wp.id}")
end
end
end
end