Files
openproject/spec/models/user_working_hours_spec.rb
Klaus Zanders ff015344dc Fix specs
2026-03-11 16:02:20 +01:00

396 lines
14 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 UserWorkingHours do
subject(:working_hours) { build(:user_working_hours) }
describe "validations" do
it { is_expected.to be_valid }
it { is_expected.to validate_presence_of(:valid_from) }
# The *_hours virtual attributes have a converting setter (hours → minutes), so
# shoulda-matchers cannot induce invalid states through it. We bypass the setter
# and write directly to the underlying minute column instead.
%i[monday tuesday wednesday thursday friday saturday sunday].each do |day|
describe "##{day}_hours" do
it "is invalid when exceeding 24 hours" do
subject.public_send(:"#{day}=", (24.5 * 60).round)
expect(subject).not_to be_valid
expect(subject.errors[:"#{day}_hours"]).to be_present
end
it "is invalid when negative" do
subject.public_send(:"#{day}=", -60)
expect(subject).not_to be_valid
expect(subject.errors[:"#{day}_hours"]).to be_present
end
it "is valid at 0 hours" do
subject.public_send(:"#{day}=", 0)
expect(subject).to be_valid
end
it "is valid at 24 hours" do
subject.public_send(:"#{day}=", 24 * 60)
expect(subject).to be_valid
end
end
end
it { is_expected.to validate_presence_of(:availability_factor) }
it do
expect(subject).to validate_numericality_of(:availability_factor).only_integer
.is_greater_than_or_equal_to(0)
.is_less_than_or_equal_to(100)
end
end
describe "hours accessors" do
subject(:working_hours) { build(:user_working_hours, monday: 480, tuesday: 90, wednesday: 0) }
%i[monday tuesday wednesday thursday friday saturday sunday].each do |day|
describe "##{day}_hours" do
it "returns the minutes value converted to hours" do
working_hours.public_send("#{day}=", 150)
expect(working_hours.public_send("#{day}_hours")).to eq(2.5)
end
end
describe "##{day}_hours=" do
it "stores the hours value converted to minutes" do
working_hours.public_send("#{day}_hours=", 7.5)
expect(working_hours.public_send(day)).to eq(450)
end
it "rounds fractional minutes" do
working_hours.public_send("#{day}_hours=", 1.0 / 3)
expect(working_hours.public_send(day)).to eq(20)
end
end
end
# The following tests cover string parsing via `to_hours` for the `monday_hours=` setter.
# The same parsing logic applies to all `{day}_hours=` setters since they are generated identically.
describe "#monday_hours= string parsing" do
subject(:working_hours) { build(:user_working_hours) }
{
"8" => 480,
"7.5" => 450,
"7,5" => 450,
"8h" => 480,
"7.5h" => 450,
"7,5h" => 450,
"7:30" => 450,
"2h30" => 150,
"2h30m" => 150,
"2h 30m" => 150,
"2h" => 120,
"30m" => 30
}.each do |input, expected_minutes|
it "parses #{input.inspect} to #{expected_minutes} minutes" do
working_hours.monday_hours = input
expect(working_hours.monday).to eq(expected_minutes)
end
end
end
it "returns 8.0 hours for a full work day of 480 minutes" do
expect(working_hours.monday_hours).to eq(8.0)
end
it "returns 1.5 hours for 90 minutes" do
expect(working_hours.tuesday_hours).to eq(1.5)
end
it "returns 0.0 for a non-working day" do
expect(working_hours.wednesday_hours).to eq(0.0)
end
end
describe "#weekly_working_hours" do
it "sums the daily working hours for the week" do
working_hours.monday = 480
working_hours.tuesday = 240
working_hours.wednesday = 0
working_hours.thursday = 120
working_hours.friday = 480
working_hours.saturday = 0
working_hours.sunday = 0
expect(working_hours.weekly_working_hours).to eq(8.0 + 4.0 + 0.0 + 2.0 + 8.0 + 0.0 + 0.0)
end
end
describe "#effective_weekly_working_hours" do
it "calculates the effective weekly working hours based on the availability factor" do
working_hours.monday = 480
working_hours.tuesday = 240
working_hours.wednesday = 0
working_hours.thursday = 120
working_hours.friday = 480
working_hours.saturday = 0
working_hours.sunday = 0
working_hours.availability_factor = 50
expect(working_hours.effective_weekly_working_hours).to eq(((8.0 + 4.0 + 0.0 + 2.0 + 8.0) / 2.0).round(2))
end
end
describe ".valid_for_date" do
let(:user) { create(:user) }
let!(:old_hours) { create(:user_working_hours, user:, valid_from: 30.days.ago) }
let!(:recent_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) }
let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) }
it "returns the most recent record valid on the given date" do
expect(described_class.for_user(user).valid_for_date(Date.current)).to eq(recent_hours)
end
it "returns the correct record for a past date" do
expect(described_class.for_user(user).valid_for_date(20.days.ago.to_date)).to eq(old_hours)
end
it "returns nil when no record is valid for the given date" do
expect(described_class.for_user(user).valid_for_date(31.days.ago.to_date)).to be_nil
end
it "does not return future records" do
expect(described_class.for_user(user).valid_for_date(Date.current)).not_to eq(future_hours)
end
end
describe ".current" do
let(:user) { create(:user) }
let!(:past_hours) { create(:user_working_hours, user:, valid_from: 10.days.ago) }
let!(:future_hours) { create(:user_working_hours, user:, valid_from: 10.days.from_now) }
it "returns the currently valid record" do
expect(described_class.for_user(user).current).to eq(past_hours)
end
it "does not return future records" do
expect(described_class.for_user(user).current).not_to eq(future_hours)
end
end
describe ".past" do
let(:user) { create(:user) }
let!(:older_hours) { create(:user_working_hours, user:, valid_from: 20.days.ago) }
let!(:recent_past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) }
let!(:future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) }
it "returns records with valid_from before today" do
expect(described_class.for_user(user).past).to contain_exactly(older_hours, recent_past_hours)
end
it "orders results descending by valid_from" do
expect(described_class.for_user(user).past).to eq([recent_past_hours, older_hours])
end
it "excludes future records" do
expect(described_class.for_user(user).past).not_to include(future_hours)
end
end
describe ".upcoming" do
let(:user) { create(:user) }
let!(:past_hours) { create(:user_working_hours, user:, valid_from: 5.days.ago) }
let!(:near_future_hours) { create(:user_working_hours, user:, valid_from: 5.days.from_now) }
let!(:far_future_hours) { create(:user_working_hours, user:, valid_from: 20.days.from_now) }
it "returns records with valid_from from today onwards" do
expect(described_class.for_user(user).upcoming).to contain_exactly(near_future_hours, far_future_hours)
end
it "orders results ascending by valid_from" do
expect(described_class.for_user(user).upcoming).to eq([near_future_hours, far_future_hours])
end
it "excludes past records" do
expect(described_class.for_user(user).upcoming).not_to include(past_hours)
end
end
describe "#working_day_ranges" do
def build_hours(**day_minutes)
attrs = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }
build(:user_working_hours, **attrs, **day_minutes)
end
it "returns Monday-Friday for a standard work week regardless of hour differences" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 360, thursday: 480, friday: 360)
expect(wh.working_day_ranges).to eq("Monday-Friday")
end
it "splits ranges at non-working days" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
expect(wh.working_day_ranges).to eq("Monday-Tuesday, Thursday-Friday")
end
it "returns a single day name when only one day is working" do
wh = build_hours(wednesday: 480)
expect(wh.working_day_ranges).to eq("Wednesday")
end
it "returns an empty string when no days are working" do
wh = build_hours
expect(wh.working_day_ranges).to eq("")
end
context "with German locale" do
around { |example| I18n.with_locale(:de) { example.run } }
it "uses full German day names" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
expect(wh.working_day_ranges).to eq("Montag-Dienstag, Donnerstag-Freitag")
end
end
end
describe "#working_days_summary" do
def build_hours(**day_minutes)
attrs = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }
build(:user_working_hours, **attrs, **day_minutes)
end
it "returns Mon-Fri 8h when all working days share the same hours" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480)
expect(wh.working_days_summary).to eq("Mon-Fri 8h")
end
it "returns Mon-Thu 8h, Fri 6h when one day differs" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 360)
expect(wh.working_days_summary).to eq("Mon-Thu 8h, Fri 6h")
end
it "returns separate segments when multiple groups alternate" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 360, thursday: 480, friday: 360)
expect(wh.working_days_summary).to eq("Mon-Tue 8h, Wed 6h, Thu 8h, Fri 6h")
end
it "splits into separate ranges when days are missing in the middle" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
expect(wh.working_days_summary).to eq("Mon-Tue 8h, Thu-Fri 8h")
end
it "returns an empty string when no days are working" do
wh = build_hours
expect(wh.working_days_summary).to eq("")
end
it "returns a single day label when only one day is working" do
wh = build_hours(friday: 480)
expect(wh.working_days_summary).to eq("Fri 8h")
end
it "formats fractional hours without trailing zeros" do
wh = build_hours(monday: 450, tuesday: 450)
expect(wh.working_days_summary).to eq("Mon-Tue 7.5h")
end
it "formats whole hours without a decimal" do
wh = build_hours(monday: 480)
expect(wh.working_days_summary).to eq("Mon 8h")
end
it "includes weekend days when they are working days" do
wh = build_hours(saturday: 240, sunday: 240)
expect(wh.working_days_summary).to eq("Sat-Sun 4h")
end
it "handles a single weekend day" do
wh = build_hours(saturday: 480)
expect(wh.working_days_summary).to eq("Sat 8h")
end
context "with German locale" do
around { |example| I18n.with_locale(:de) { example.run } }
it "uses German abbreviations for a simple Mon-Fri range" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480)
expect(wh.working_days_summary).to eq("Mo-Fr 8h")
end
it "uses German abbreviations when one day differs" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 360)
expect(wh.working_days_summary).to eq("Mo-Do 8h, Fr 6h")
end
it "uses German abbreviations when days are missing in the middle" do
wh = build_hours(monday: 480, tuesday: 480, wednesday: 0, thursday: 480, friday: 480)
expect(wh.working_days_summary).to eq("Mo-Di 8h, Do-Fr 8h")
end
it "uses German abbreviations for weekend days" do
wh = build_hours(saturday: 240, sunday: 240)
expect(wh.working_days_summary).to eq("Sa-So 4h")
end
it "uses a comma as the decimal separator for fractional hours" do
wh = build_hours(monday: 450, tuesday: 450)
expect(wh.working_days_summary).to eq("Mo-Di 7,5h")
end
end
end
describe ".visible" do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let!(:user_hours) { create(:user_working_hours, user:) }
let!(:other_hours) { create(:user_working_hours, user: other_user) }
context "when the viewer has :manage_working_times permission" do
let(:viewer) { create(:user, global_permissions: [:manage_working_times]) }
it "returns all records" do
expect(described_class.visible(viewer)).to contain_exactly(user_hours, other_hours)
end
end
context "when the viewer has no special permissions" do
let(:viewer) { create(:user) }
let!(:viewer_hours) { create(:user_working_hours, user: viewer) }
it "returns only their own records" do
expect(described_class.visible(viewer)).to contain_exactly(viewer_hours)
end
it "excludes other users' records" do
expect(described_class.visible(viewer)).not_to include(user_hours, other_hours)
end
end
end
end