Commit 9dba0a79 authored by Sean McGivern's avatar Sean McGivern

Merge branch '262860-generate-shifts-dynamically' into 'master'

Generate shifts for a timeframe based on on-call rotation params

See merge request gitlab-org/gitlab!49665
parents 846e04c8 b13eb238
......@@ -11,6 +11,8 @@ module IncidentManagement
belongs_to :user, class_name: 'User', foreign_key: :user_id
has_many :shifts, class_name: 'OncallShift', inverse_of: :participant, foreign_key: :participant_id
scope :ordered_asc, -> { order(id: :asc) }
# Uniqueness validations added here should be duplicated
# in IncidentManagement::OncallRotation::CreateService
# as bulk insertion skips validations
......
......@@ -19,9 +19,14 @@ module IncidentManagement
validates :name, presence: true, uniqueness: { scope: :oncall_schedule_id }, length: { maximum: NAME_LENGTH }
validates :starts_at, presence: true
validates :length, presence: true
validates :length, presence: true, numericality: true
validates :length_unit, presence: true
delegate :project, to: :schedule
def shift_duration
# As length_unit is an enum, input is guaranteed to be appropriate
length.public_send(length_unit) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
# frozen_string_literal: true
module IncidentManagement
class OncallShiftGenerator
# @param rotation [IncidentManagement::OncallRotation]
def initialize(rotation)
@rotation = rotation
end
# Generates an array of shifts which cover the provided time range.
#
# @param starts_at [ActiveSupport::TimeWithZone]
# @param ends_at [ActiveSupport::TimeWithZone]
# @return [IncidentManagement::OncallShift]
def for_timeframe(starts_at:, ends_at:)
starts_at = [apply_timezone(starts_at), rotation_starts_at].max
ends_at = apply_timezone(ends_at)
return [] unless starts_at < ends_at
return [] unless rotation.participants.any?
# The first shift within the timeframe may begin before
# the timeframe. We want to begin generating shifts
# based on the actual start time of the shift.
elapsed_shift_count = elapsed_whole_shifts(starts_at)
shift_starts_at = shift_start_time(elapsed_shift_count)
shifts = []
while shift_starts_at < ends_at
shifts << shift_for(elapsed_shift_count, shift_starts_at)
shift_starts_at += shift_duration
elapsed_shift_count += 1
end
shifts
end
# Generates a single shift during which the timestamp occurs.
#
# @param timestamp [ActiveSupport::TimeWithZone]
# @return IncidentManagement::OncallShift
def for_timestamp(timestamp)
timestamp = apply_timezone(timestamp)
return if timestamp < rotation_starts_at
return unless rotation.participants.any?
elapsed_shift_count = elapsed_whole_shifts(timestamp)
shift_starts_at = shift_start_time(elapsed_shift_count)
shift_for(elapsed_shift_count, shift_starts_at)
end
private
attr_reader :rotation
delegate :shift_duration, to: :rotation
# Starting time of a shift which covers the timestamp.
# @return [ActiveSupport::TimeWithZone]
def shift_start_time(elapsed_shift_count)
rotation_starts_at + (elapsed_shift_count * shift_duration)
end
# Total completed shifts passed between rotation start
# time and the provided timestamp.
# @return [Integer]
def elapsed_whole_shifts(timestamp)
elapsed_duration = timestamp - rotation_starts_at
unless rotation.hours?
# Changing timezones (like during daylight savings) can
# cause a "day" to have a duration other than 24 hours ("weeks" too).
# Since elapsed_duration is in seconds, we need
# account for this variable day/week length to
# determine how many actual shifts have elapsed.
#
# Ex) If a location with daylight savings sets their
# clocks forward an hour, a 1-day shift will last for
# 23 hours if it occurs over that transition.
#
# If we want to generate a shift which occurs 1 week
# after the timezone change, the real elapsed seconds
# will equal 1 week minus an hour.
#
# Seconds per average week: 2 * 7 * 24 * 60 * 60 = 1209600
# Seconds in zone-shifted week: 1209600 - (60 * 60) = 1206000
#
# If we count in seconds, minutes, or hours, these are different durations.
# If we count in "days" or "weeks", these durations are equivalent.
#
# To determine how many effective days or weeks
# a duration (in seconds) was, we need to normalize
# the duration to fit the definition of a 24-hour day.
# We can do this by diffing the UTC-offsets between the
# start time of the rotation and the relevant timestamp.
# This should account for different hemispheres,
# offsets changes other an 1 hour, and one-off timezone changes.
elapsed_duration += timestamp.utc_offset - rotation_starts_at.utc_offset
end
# Uses #round to account for floating point inconsistencies.
(elapsed_duration / shift_duration).round(5).floor
end
# Returns an UNSAVED shift, as this shift won't necessarily
# be persisted.
# @return [IncidentManagement::OncallShift]
def shift_for(elapsed_shift_count, shift_starts_at)
IncidentManagement::OncallShift.new(
rotation: rotation,
participant: participants[participant_rank(elapsed_shift_count)],
starts_at: shift_starts_at,
ends_at: shift_starts_at + shift_duration
)
end
# Position in an array of participants based on the
# number of shifts which have elasped for the rotation.
# @return [Integer]
def participant_rank(elapsed_shifts_count)
elapsed_shifts_count % participants.length
end
def participants
@participants ||= rotation.participants.ordered_asc
end
def rotation_starts_at
@rotation_starts_at ||= apply_timezone(rotation.starts_at)
end
def apply_timezone(timestamp)
timestamp.in_time_zone(rotation.schedule.timezone)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::OncallShiftGenerator do
let_it_be(:rotation_start_time) { Time.parse('2020-12-08 00:00:00 UTC').utc }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length: 5, length_unit: :days) }
let(:current_time) { Time.parse('2020-12-08 15:00:00 UTC').utc }
let(:shift_length) { rotation.shift_duration }
around do |example|
travel_to(current_time) { example.run }
end
shared_context 'with three participants' do
let_it_be(:participant1) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:participant2) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:participant3) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
end
# Compares generated shifts to expected output.
# params:
# description -> String
# shift_params -> formatted as [[participant identifier(Symbol), start_time(String), end_time(String)]].
# start_time & end_time should include offset/UTC identifier.
# participant identifier should reference the variable name of a participant.
#
# Example) [[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC']]
# :participant2 would reference `let(:participant2)`
shared_examples 'unsaved shifts' do |description, shift_params|
it "returns #{description}" do
expect(shifts).to all(be_a(IncidentManagement::OncallShift))
expect(shifts.length).to eq(shift_params.length)
shifts.each_with_index do |shift, idx|
expect(shift).to have_attributes(
id: nil,
rotation: rotation,
participant: send(shift_params[idx][0]),
starts_at: Time.zone.parse(shift_params[idx][1]),
ends_at: Time.zone.parse(shift_params[idx][2])
)
end
end
end
# For asserting the response is a singular shift rather
# than an array of shifts
shared_examples 'unsaved shift' do |description, shift_params|
let(:shifts) { [shift] }
include_examples 'unsaved shifts', description, [shift_params]
end
describe '#for_timeframe' do
let(:starts_at) { Time.parse('2020-12-08 02:00:00 UTC').utc }
let(:ends_at) { starts_at + (shift_length * 2) }
subject(:shifts) { described_class.new(rotation).for_timeframe(starts_at: starts_at, ends_at: ends_at) }
context 'with no participants' do
it { is_expected.to be_empty }
end
context 'with one participant' do
let_it_be(:participant) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
it_behaves_like 'unsaved shifts',
'3 shifts of 5 days, all for the same participant',
[[:participant, '2020-12-08 00:00:00 UTC', '2020-12-13 00:00:00 UTC'],
[:participant, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC'],
[:participant, '2020-12-18 00:00:00 UTC', '2020-12-23 00:00:00 UTC']]
end
context 'with many participants' do
include_context 'with three participants'
context 'when end time is earlier than start time' do
let(:ends_at) { starts_at - 1.hour }
it { is_expected.to be_empty }
end
context 'when start time is the same time as the rotation start time' do
let(:starts_at) { rotation_start_time }
it_behaves_like 'unsaved shifts',
'2 shifts of 5 days starting with first participant at the rotation start time',
[[:participant1, '2020-12-08 00:00:00 UTC', '2020-12-13 00:00:00 UTC'],
[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC']]
end
context 'when start time is earlier than the rotation start time' do
let(:starts_at) { 1.day.before(rotation_start_time) }
it_behaves_like 'unsaved shifts',
'2 shifts of 5 days starting with the first participant at the rotation start time',
[[:participant1, '2020-12-08 00:00:00 UTC', '2020-12-13 00:00:00 UTC'],
[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC']]
end
context 'when start time coincides with a shift change' do
let(:starts_at) { rotation_start_time + shift_length }
it_behaves_like 'unsaved shifts',
'2 shifts of 5 days, starting with the second participant and the second shift',
[[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC'],
[:participant3, '2020-12-18 00:00:00 UTC', '2020-12-23 00:00:00 UTC']]
end
context 'when start time is partway through a shift' do
let(:starts_at) { rotation_start_time + (0.6 * shift_length) }
it_behaves_like 'unsaved shifts',
'3 shifts of 5 days staring with the first participant which includes the partially completed shift',
[[:participant1, '2020-12-08 00:00:00 UTC', '2020-12-13 00:00:00 UTC'],
[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC'],
[:participant3, '2020-12-18 00:00:00 UTC', '2020-12-23 00:00:00 UTC']]
end
context 'when the rotation has been completed many times over' do
let(:starts_at) { rotation_start_time + 7.weeks }
it_behaves_like 'unsaved shifts',
'3 shifts of 5 days starting with the first participant beginning 7 weeks after rotation start time',
[[:participant1, '2021-01-22 00:00:00 UTC', '2021-01-27 00:00:00 UTC'],
[:participant2, '2021-01-27 00:00:00 UTC', '2021-02-01 00:00:00 UTC'],
[:participant3, '2021-02-01 00:00:00 UTC', '2021-02-06 00:00:00 UTC']]
end
context 'when timeframe covers the rotation many times over' do
let(:ends_at) { starts_at + (shift_length * 6.8) }
it_behaves_like 'unsaved shifts',
'7 shifts of 5 days starting with the first participant',
[[:participant1, '2020-12-08 00:00:00 UTC', '2020-12-13 00:00:00 UTC'],
[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC'],
[:participant3, '2020-12-18 00:00:00 UTC', '2020-12-23 00:00:00 UTC'],
[:participant1, '2020-12-23 00:00:00 UTC', '2020-12-28 00:00:00 UTC'],
[:participant2, '2020-12-28 00:00:00 UTC', '2021-01-02 00:00:00 UTC'],
[:participant3, '2021-01-02 00:00:00 UTC', '2021-01-07 00:00:00 UTC'],
[:participant1, '2021-01-07 00:00:00 UTC', '2021-01-12 00:00:00 UTC']]
end
end
context 'in timezones with daylight-savings' do
context 'with positive UTC offsets' do
let_it_be(:schedule) { create(:incident_management_oncall_schedule, timezone: 'Pacific/Auckland') }
context 'with rotation in hours' do
context 'switching to daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('Pacific/Auckland').parse('2020-09-27').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :hours, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 5.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which start in NZST(+1200) and switch to NZDT(+1300)',
[[:participant1, '2020-09-27 00:00:00 +1200', '2020-09-27 01:00:00 +1200'],
[:participant2, '2020-09-27 01:00:00 +1200', '2020-09-27 02:00:00 +1200'],
[:participant3, '2020-09-27 03:00:00 +1300', '2020-09-27 04:00:00 +1300'],
[:participant1, '2020-09-27 04:00:00 +1300', '2020-09-27 05:00:00 +1300'],
[:participant2, '2020-09-27 05:00:00 +1300', '2020-09-27 06:00:00 +1300']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.hours }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely in NZDT(+1300)',
[[:participant2, '2020-09-27 05:00:00 +1300', '2020-09-27 06:00:00 +1300'],
[:participant3, '2020-09-27 06:00:00 +1300', '2020-09-27 07:00:00 +1300'],
[:participant1, '2020-09-27 07:00:00 +1300', '2020-09-27 08:00:00 +1300']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('Pacific/Auckland').parse('2021-04-06').beginning_of_day }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely back in NZST(+1200) after 2 timezone switches since the rotation start time',
[[:participant1, '2021-04-06 00:00:00 +1200', '2021-04-06 01:00:00 +1200'],
[:participant2, '2021-04-06 01:00:00 +1200', '2021-04-06 02:00:00 +1200'],
[:participant3, '2021-04-06 02:00:00 +1200', '2021-04-06 03:00:00 +1200']]
end
end
context 'switching off daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('Pacific/Auckland').parse('2021-04-04').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :hours, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 5.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which start in NZDT(+1300) and switch to NZST(+1200)',
[[:participant1, '2021-04-04 00:00:00 +1300', '2021-04-04 01:00:00 +1300'],
[:participant2, '2021-04-04 01:00:00 +1300', '2021-04-04 02:00:00 +1300'],
[:participant3, '2021-04-04 02:00:00 +1300', '2021-04-04 02:00:00 +1200'],
[:participant1, '2021-04-04 02:00:00 +1200', '2021-04-04 03:00:00 +1200'],
[:participant2, '2021-04-04 03:00:00 +1200', '2021-04-04 04:00:00 +1200']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.hours }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely in NZST(+1200)',
[[:participant2, '2021-04-04 03:00:00 +1200', '2021-04-04 04:00:00 +1200'],
[:participant3, '2021-04-04 04:00:00 +1200', '2021-04-04 05:00:00 +1200'],
[:participant1, '2021-04-04 05:00:00 +1200', '2021-04-04 06:00:00 +1200']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('Pacific/Auckland').parse('2021-09-27').beginning_of_day }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely back in NZST(+1300) after 2 timezone switches since the rotation start time',
[[:participant1, '2021-09-27 00:00:00 +1300', '2021-09-27 01:00:00 +1300'],
[:participant2, '2021-09-27 01:00:00 +1300', '2021-09-27 02:00:00 +1300'],
[:participant3, '2021-09-27 02:00:00 +1300', '2021-09-27 03:00:00 +1300']]
end
end
end
context 'with rotation in days' do
context 'switching to daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('Pacific/Auckland').parse('2020-09-26').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :days, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 4.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which start in NZST(+1200) and switch to NZDT(+1300)',
[[:participant1, '2020-09-26 00:00:00 +1200', '2020-09-27 00:00:00 +1200'],
[:participant2, '2020-09-27 00:00:00 +1200', '2020-09-28 00:00:00 +1300'],
[:participant3, '2020-09-28 00:00:00 +1300', '2020-09-29 00:00:00 +1300'],
[:participant1, '2020-09-29 00:00:00 +1300', '2020-09-30 00:00:00 +1300']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 3.days }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely in NZDT(+1300)',
[[:participant1, '2020-09-29 00:00:00 +1300', '2020-09-30 00:00:00 +1300'],
[:participant2, '2020-09-30 00:00:00 +1300', '2020-10-01 00:00:00 +1300'],
[:participant3, '2020-10-01 00:00:00 +1300', '2020-10-02 00:00:00 +1300']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('Pacific/Auckland').parse('2021-04-07').beginning_of_day }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely back in NZST(+1200) after 2 timezone switches since the rotation start time',
[[:participant2, '2021-04-07 00:00:00 +1200', '2021-04-08 00:00:00 +1200'],
[:participant3, '2021-04-08 00:00:00 +1200', '2021-04-09 00:00:00 +1200'],
[:participant1, '2021-04-09 00:00:00 +1200', '2021-04-10 00:00:00 +1200']]
end
end
context 'switching off daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('Pacific/Auckland').parse('2021-04-03').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :days, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 4.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which start in NZDT(+1300) and switch to NZST(+1200)',
[[:participant1, '2021-04-03 00:00:00 +1300', '2021-04-04 00:00:00 +1300'],
[:participant2, '2021-04-04 00:00:00 +1300', '2021-04-05 00:00:00 +1200'],
[:participant3, '2021-04-05 00:00:00 +1200', '2021-04-06 00:00:00 +1200'],
[:participant1, '2021-04-06 00:00:00 +1200', '2021-04-07 00:00:00 +1200']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 3.days }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely in NZST(+1200)',
[[:participant1, '2021-04-06 00:00:00 +1200', '2021-04-07 00:00:00 +1200'],
[:participant2, '2021-04-07 00:00:00 +1200', '2021-04-08 00:00:00 +1200'],
[:participant3, '2021-04-08 00:00:00 +1200', '2021-04-09 00:00:00 +1200']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('Pacific/Auckland').parse('2021-09-28').beginning_of_day }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely back in NZST(+1300) after 2 timezone switches since the rotation start time',
[[:participant2, '2021-09-28 00:00:00 +1300', '2021-09-29 00:00:00 +1300'],
[:participant3, '2021-09-29 00:00:00 +1300', '2021-09-30 00:00:00 +1300'],
[:participant1, '2021-09-30 00:00:00 +1300', '2021-10-01 00:00:00 +1300']]
end
end
end
context 'with rotation in weeks' do
context 'switching to daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('Pacific/Auckland').parse('2020-09-01').at_noon }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :weeks, length: 2, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 6.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which start in NZST(+1200) and switch to NZDT(+1300)',
[[:participant1, '2020-09-01 12:00:00 +1200', '2020-09-15 12:00:00 +1200'],
[:participant2, '2020-09-15 12:00:00 +1200', '2020-09-29 12:00:00 +1300'],
[:participant3, '2020-09-29 12:00:00 +1300', '2020-10-13 12:00:00 +1300']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.weeks }
let(:ends_at) { starts_at + 4.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely in NZDT(+1300)',
[[:participant3, '2020-09-29 12:00:00 +1300', '2020-10-13 12:00:00 +1300'],
[:participant1, '2020-10-13 12:00:00 +1300', '2020-10-27 12:00:00 +1300']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('Pacific/Auckland').parse('2021-04-18').at_noon }
let(:ends_at) { starts_at + 5.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely back in NZST(+1200) after 2 timezone switches since the rotation start time',
[[:participant2, '2021-04-13 12:00:00 +1200', '2021-04-27 12:00:00 +1200'],
[:participant3, '2021-04-27 12:00:00 +1200', '2021-05-11 12:00:00 +1200'],
[:participant1, '2021-05-11 12:00:00 +1200', '2021-05-25 12:00:00 +1200']]
end
end
context 'switching off daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('Pacific/Auckland').parse('2021-03-21').at_noon }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :weeks, length: 2, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 6.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which start in NZDT(+1300) and switch to NZST(+1200)',
[[:participant1, '2021-03-21 12:00:00 +1300', '2021-04-04 12:00:00 +1200'],
[:participant2, '2021-04-04 12:00:00 +1200', '2021-04-18 12:00:00 +1200'],
[:participant3, '2021-04-18 12:00:00 +1200', '2021-05-02 12:00:00 +1200']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.weeks }
let(:ends_at) { starts_at + 4.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely in NZST(+1200)',
[[:participant3, '2021-04-18 12:00:00 +1200', '2021-05-02 12:00:00 +1200'],
[:participant1, '2021-05-02 12:00:00 +1200', '2021-05-16 12:00:00 +1200']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('Pacific/Auckland').parse('2021-09-30').at_noon }
let(:ends_at) { starts_at + 5.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely back in NZST(+1200) after 2 timezone switches since the rotation start time',
[[:participant2, '2021-09-19 12:00:00 +1200', '2021-10-03 12:00:00 +1300'],
[:participant3, '2021-10-03 12:00:00 +1300', '2021-10-17 12:00:00 +1300'],
[:participant1, '2021-10-17 12:00:00 +1300', '2021-10-31 12:00:00 +1300'],
[:participant2, '2021-10-31 12:00:00 +1300', '2021-11-14 12:00:00 +1300']]
end
end
end
end
context 'with negative UTC offsets' do
let_it_be(:schedule) { create(:incident_management_oncall_schedule, timezone: 'America/New_York') }
context 'with rotation in hours' do
context 'switching to daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('America/New_York').parse('2021-03-14').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :hours, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 5.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which start in EST(-0500) and switch to EDT(-0400)',
[[:participant1, '2021-03-14 00:00:00 -0500', '2021-03-14 01:00:00 -0500'],
[:participant2, '2021-03-14 01:00:00 -0500', '2021-03-14 02:00:00 -0500'],
[:participant3, '2021-03-14 03:00:00 -0400', '2021-03-14 04:00:00 -0400'],
[:participant1, '2021-03-14 04:00:00 -0400', '2021-03-14 05:00:00 -0400'],
[:participant2, '2021-03-14 05:00:00 -0400', '2021-03-14 06:00:00 -0400']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.hours }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely in EDT(-0400)',
[[:participant2, '2021-03-14 05:00:00 -0400', '2021-03-14 06:00:00 -0400'],
[:participant3, '2021-03-14 06:00:00 -0400', '2021-03-14 07:00:00 -0400'],
[:participant1, '2021-03-14 07:00:00 -0400', '2021-03-14 08:00:00 -0400']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('America/New_York').parse('2021-11-08').beginning_of_day }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely back in EST(-0500) after 2 timezone switches since the rotation start time',
[[:participant1, '2021-11-08 00:00:00 -0500', '2021-11-08 01:00:00 -0500'],
[:participant2, '2021-11-08 01:00:00 -0500', '2021-11-08 02:00:00 -0500'],
[:participant3, '2021-11-08 02:00:00 -0500', '2021-11-08 03:00:00 -0500']]
end
end
context 'switching off daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('America/New_York').parse('2021-11-07').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :hours, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 5.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which start in EDT(-0400) and switch to EST(-0500)',
[[:participant1, '2021-11-07 00:00:00 -0400', '2021-11-07 01:00:00 -0400'],
[:participant2, '2021-11-07 01:00:00 -0400', '2021-11-07 02:00:00 -0400'],
[:participant3, '2021-11-07 02:00:00 -0400', '2021-11-07 02:00:00 -0500'],
[:participant1, '2021-11-07 02:00:00 -0500', '2021-11-07 03:00:00 -0500'],
[:participant2, '2021-11-07 03:00:00 -0500', '2021-11-07 04:00:00 -0500']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.hours }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely in EST(-0500)',
[[:participant2, '2021-11-07 03:00:00 -0500', '2021-11-07 04:00:00 -0500'],
[:participant3, '2021-11-07 04:00:00 -0500', '2021-11-07 05:00:00 -0500'],
[:participant1, '2021-11-07 05:00:00 -0500', '2021-11-07 06:00:00 -0500']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('America/New_York').parse('2022-03-14').beginning_of_day }
let(:ends_at) { starts_at + 3.hours }
it_behaves_like 'unsaved shifts',
'hour-long shifts which are entirely back in EDT(-0400) after 2 timezone switches since the rotation start time',
[[:participant1, '2022-03-14 00:00:00 -0400', '2022-03-14 01:00:00 -0400'],
[:participant2, '2022-03-14 01:00:00 -0400', '2022-03-14 02:00:00 -0400'],
[:participant3, '2022-03-14 02:00:00 -0400', '2022-03-14 03:00:00 -0400']]
end
end
end
context 'with rotation in days' do
context 'switching to daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('America/New_York').parse('2021-03-13').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :days, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 4.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which start in EST(-0500) and switch to EDT(-0400)',
[[:participant1, '2021-03-13 00:00:00 -0500', '2021-03-14 00:00:00 -0500'],
[:participant2, '2021-03-14 00:00:00 -0500', '2021-03-15 00:00:00 -0400'],
[:participant3, '2021-03-15 00:00:00 -0400', '2021-03-16 00:00:00 -0400'],
[:participant1, '2021-03-16 00:00:00 -0400', '2021-03-17 00:00:00 -0400']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 3.days }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely in EDT(-0400)',
[[:participant1, '2021-03-16 00:00:00 -0400', '2021-03-17 00:00:00 -0400'],
[:participant2, '2021-03-17 00:00:00 -0400', '2021-03-18 00:00:00 -0400'],
[:participant3, '2021-03-18 00:00:00 -0400', '2021-03-19 00:00:00 -0400']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('America/New_York').parse('2021-11-10').beginning_of_day }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely back in EST(-0500) after 2 timezone switches since the rotation start time',
[[:participant3, '2021-11-10 00:00:00 -0500', '2021-11-11 00:00:00 -0500'],
[:participant1, '2021-11-11 00:00:00 -0500', '2021-11-12 00:00:00 -0500'],
[:participant2, '2021-11-12 00:00:00 -0500', '2021-11-13 00:00:00 -0500']]
end
end
context 'switching off daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('America/New_York').parse('2021-11-06').beginning_of_day }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :days, length: 1, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 4.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which start in EDT(-0400) and switch to EST(-0500)',
[[:participant1, '2021-11-06 00:00:00 -0400', '2021-11-07 00:00:00 -0400'],
[:participant2, '2021-11-07 00:00:00 -0400', '2021-11-08 00:00:00 -0500'],
[:participant3, '2021-11-08 00:00:00 -0500', '2021-11-09 00:00:00 -0500'],
[:participant1, '2021-11-09 00:00:00 -0500', '2021-11-10 00:00:00 -0500']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 3.days }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely in EST(-0500)',
[[:participant1, '2021-11-09 00:00:00 -0500', '2021-11-10 00:00:00 -0500'],
[:participant2, '2021-11-10 00:00:00 -0500', '2021-11-11 00:00:00 -0500'],
[:participant3, '2021-11-11 00:00:00 -0500', '2021-11-12 00:00:00 -0500']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('America/New_York').parse('2022-03-15').beginning_of_day }
let(:ends_at) { starts_at + 3.days }
it_behaves_like 'unsaved shifts',
'day-long shifts which are entirely back in EDT(-0400) after 2 timezone switches since the rotation start time',
[[:participant1, '2022-03-15 00:00:00 -0400', '2022-03-16 00:00:00 -0400'],
[:participant2, '2022-03-16 00:00:00 -0400', '2022-03-17 00:00:00 -0400'],
[:participant3, '2022-03-17 00:00:00 -0400', '2022-03-18 00:00:00 -0400']]
end
end
end
context 'with rotation in weeks' do
context 'switching to daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('America/New_York').parse('2021-02-25').at_noon }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :weeks, length: 2, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 6.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which start in EST(-0500) and switch to EDT(-0400)',
[[:participant1, '2021-02-25 12:00:00 -0500', '2021-03-11 12:00:00 -0500'],
[:participant2, '2021-03-11 12:00:00 -0500', '2021-03-25 12:00:00 -0400'],
[:participant3, '2021-03-25 12:00:00 -0400', '2021-04-08 12:00:00 -0400']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.weeks }
let(:ends_at) { starts_at + 4.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely in EDT(-0400)',
[[:participant3, '2021-03-25 12:00:00 -0400', '2021-04-08 12:00:00 -0400'],
[:participant1, '2021-04-08 12:00:00 -0400', '2021-04-22 12:00:00 -0400']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('America/New_York').parse('2021-11-23').at_noon }
let(:ends_at) { starts_at + 5.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely back in EST(-0500) after 2 timezone switches since the rotation start time',
[[:participant2, '2021-11-18 12:00:00 -0500', '2021-12-02 12:00:00 -0500'],
[:participant3, '2021-12-02 12:00:00 -0500', '2021-12-16 12:00:00 -0500'],
[:participant1, '2021-12-16 12:00:00 -0500', '2021-12-30 12:00:00 -0500']]
end
end
context 'switching off daylight savings time' do
let_it_be(:rotation_start_time) { Time.find_zone('America/New_York').parse('2021-10-26').at_noon }
let_it_be(:rotation) { create(:incident_management_oncall_rotation, starts_at: rotation_start_time, length_unit: :weeks, length: 2, schedule: schedule) }
include_context 'with three participants'
context 'when overlapping the switch' do
let(:starts_at) { rotation_start_time }
let(:ends_at) { starts_at + 6.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which start in EDT(-0400) and switch to EST(-0500)',
[[:participant1, '2021-10-26 12:00:00 -0400', '2021-11-09 12:00:00 -0500'],
[:participant2, '2021-11-09 12:00:00 -0500', '2021-11-23 12:00:00 -0500'],
[:participant3, '2021-11-23 12:00:00 -0500', '2021-12-07 12:00:00 -0500']]
end
context 'starting after switch' do
let(:starts_at) { rotation_start_time + 4.weeks }
let(:ends_at) { starts_at + 4.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely in EST(-0500)',
[[:participant3, '2021-11-23 12:00:00 -0500', '2021-12-07 12:00:00 -0500'],
[:participant1, '2021-12-07 12:00:00 -0500', '2021-12-21 12:00:00 -0500']]
end
context 'starting after multiple switches' do
let(:starts_at) { Time.find_zone('America/New_York').parse('2022-03-17').at_noon }
let(:ends_at) { starts_at + 5.weeks }
it_behaves_like 'unsaved shifts',
'2-week-long shifts which are entirely back in EDT(-0400) after 2 timezone switches since the rotation start time',
[[:participant2, '2022-03-15 12:00:00 -0400', '2022-03-29 12:00:00 -0400'],
[:participant3, '2022-03-29 12:00:00 -0400', '2022-04-12 12:00:00 -0400'],
[:participant1, '2022-04-12 12:00:00 -0400', '2022-04-26 12:00:00 -0400']]
end
end
end
end
end
end
describe '#for_timestamp' do
let(:timestamp) { current_time }
subject(:shift) { described_class.new(rotation).for_timestamp(timestamp) }
context 'with no participants' do
it { is_expected.to be_nil }
end
context 'with participants' do
include_context 'with three participants'
context 'when timestamp is before the rotation start time' do
let(:timestamp) { rotation_start_time - 10.minutes }
it { is_expected.to be_nil }
end
context 'when timestamp matches the rotation start time' do
let(:timestamp) { rotation_start_time }
it_behaves_like 'unsaved shift',
'shift which starts at the same time as the rotation',
[:participant1, '2020-12-08 00:00:00 UTC', '2020-12-13 00:00:00 UTC']
end
context 'when timestamp matches a shift start/end time' do
let(:timestamp) { rotation_start_time + shift_length }
it_behaves_like 'unsaved shift',
'the next shift of the rotation',
[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC']
end
context 'when timestamp is in the middle of a shift' do
let(:timestamp) { rotation_start_time + (1.6 * shift_length) }
it_behaves_like 'unsaved shift',
'the shift during which the timestamp occurs',
[:participant2, '2020-12-13 00:00:00 UTC', '2020-12-18 00:00:00 UTC']
end
end
end
end
......@@ -14,13 +14,13 @@ RSpec.describe IncidentManagement::OncallParticipant do
rotation.project.add_developer(user)
end
describe '.associations' do
describe 'associations' do
it { is_expected.to belong_to(:rotation) }
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:shifts) }
end
describe '.validations' do
describe 'validations' do
it { is_expected.to validate_presence_of(:rotation) }
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:color_weight) }
......@@ -38,6 +38,18 @@ RSpec.describe IncidentManagement::OncallParticipant do
end
end
describe 'scopes' do
describe '.ordered_asc' do
let_it_be(:participant1) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:participant2) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:participant3) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
subject { described_class.ordered_asc }
it { is_expected.to eq([participant1, participant2, participant3]) }
end
end
private
def remove_user_from_project(user, project)
......
......@@ -20,6 +20,7 @@ RSpec.describe IncidentManagement::OncallRotation do
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:oncall_schedule_id) }
it { is_expected.to validate_presence_of(:starts_at) }
it { is_expected.to validate_presence_of(:length) }
it { is_expected.to validate_numericality_of(:length) }
it { is_expected.to validate_presence_of(:length_unit) }
context 'when the oncall rotation with the same name exists' do
......@@ -33,4 +34,20 @@ RSpec.describe IncidentManagement::OncallRotation do
end
end
end
describe '#shift_duration' do
let_it_be(:rotation) { create(:incident_management_oncall_rotation, schedule: schedule, length: 5, length_unit: :days) }
subject { rotation.shift_duration }
it { is_expected.to eq(5.days) }
described_class.length_units.each_key do |unit|
context "with a length unit of #{unit}" do
let(:rotation) { build(:incident_management_oncall_rotation, schedule: schedule, length_unit: unit) }
it { is_expected.to be_a(ActiveSupport::Duration) }
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment