-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathuser_working_hours_spec.rb
More file actions
481 lines (394 loc) · 17.6 KB
/
Copy pathuser_working_hours_spec.rb
File metadata and controls
481 lines (394 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# 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
describe "with a nil weekday column (e.g. omitted from a create payload)" do
# Regression tests: presence/numericality validation on a `*_hours`
# attribute invokes the corresponding getter, which used to crash with
# NoMethodError on `nil / 60.0` instead of producing a clean
# validation error.
it "is invalid rather than raising when a single weekday is nil" do
subject.public_send(:wednesday=, nil)
expect { subject.valid? }.not_to raise_error
expect(subject).not_to be_valid
expect(subject.errors[:wednesday_hours]).to be_present
end
it "is invalid rather than raising when every weekday is nil" do
# Also exercises the second, independent nil-unsafe site in
# at_least_one_working_day_selected, only reachable once every
# weekday is nil (each weekday's own presence/numericality
# validation would otherwise short-circuit first).
%i[monday tuesday wednesday thursday friday saturday sunday].each do |day|
subject.public_send(:"#{day}=", nil)
end
expect { subject.valid? }.not_to raise_error
expect(subject).not_to be_valid
expect(subject.errors[:days]).to be_present
end
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
it "returns nil rather than raising when the underlying minutes column is nil" do
# Regression test: a still-nil column (e.g. a weekday omitted from
# a create payload, no DB default) used to crash with
# NoMethodError on `nil / 60.0` -- both here, and via the
# presence/numericality validator that calls this same getter.
working_hours.public_send("#{day}=", nil)
expect(working_hours.public_send("#{day}_hours")).to be_nil
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 "#minutes_on" do
before do
working_hours.monday = 480
working_hours.saturday = 0
working_hours.sunday = 120
end
it "returns the minutes defined for the date's week day" do
expect(working_hours.minutes_on(Date.new(2026, 8, 3))).to eq(480) # Monday
expect(working_hours.minutes_on(Date.new(2026, 8, 1))).to eq(0) # Saturday
expect(working_hours.minutes_on(Date.new(2026, 8, 2))).to eq(120) # Sunday
end
end
describe "#effective_minutes_on" do
let(:monday) { Date.new(2026, 8, 3) }
let(:saturday) { Date.new(2026, 8, 1) }
before do
working_hours.monday = 480
working_hours.saturday = 0
end
it "reduces the week day's minutes by the availability factor" do
working_hours.availability_factor = 75
expect(working_hours.effective_minutes_on(monday)).to eq(360)
end
it "returns the full minutes at full availability" do
working_hours.availability_factor = 100
expect(working_hours.effective_minutes_on(monday)).to eq(480)
end
it "rounds to whole minutes" do
working_hours.monday = 450
working_hours.availability_factor = 33
expect(working_hours.effective_minutes_on(monday)).to eq(149) # 148.5 rounded
end
it "stays at zero for a day without working minutes" do
working_hours.availability_factor = 75
expect(working_hours.effective_minutes_on(saturday)).to eq(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