First some FactoryBot and Rails tips.
Do not use Date.today in Rails, it is not aware of time zones. Use Time.zone.today. And these are Times, not Dates, so Time.current is more appropriate. Finally, unless all your appointments are in the past, use 2.years.since.
The convention for timestamps is to end them with _at. start_at and end_at. This also avoids confusion about start_date which is a Time, not a Date.
We can take advantage of ActiveSupport::Duration and its Numeric extensions to add to the start_date. end_date { start_date + 1.hour }.
Rather than hard coding the assumptions for a specific test into the factory, we can use a trait to make the assumptions explicit.
factory :appointment do
# These are the normal conditions.
# end_at will be 15 to 180 minutes after start_at.
start_at { Faker::Time.between(from: 2.years.ago, to: 2.years.since) }
end_at { start_at + rand(15..180).minutes }
# This is a specific trait putting end_at an hour after start_at.
trait :in_one_hour do
end_at { start_at + 1.hour }
end
end
# An appointment of 1 hour which started yesterday
appointment = build(:appointment, :in_one_hour, start_at: 1.day.ago)
We can make this better. What if we want a different duration? Instead of a trait, use a transient attribute. This lets you send an attribute to the factory which is not an attribute of the object. Like duration.
factory :appointment do
transient do
duration { rand(15..180).minutes }
end
start_at { Faker::Time.between(from: 2.years.ago, to: 2.years.since) }
end_at { start_at + duration }
end
# An appointment with a random but reasonable duration.
p build(:appointment)
# An appointment with a duration of exactly 1 hour.
p build(:appointment, duration: 1.hour)
# An appointment lasting 30 minutes starting yesterday.
p build(:appointment, duration: 30.minutes, start_at: 1.day.ago)
There's a problem. What if the caller changes the end_at? Then the start_at should be based on the end_at. But if they set the start_at the end_at needs to be based on the start_at. That leads to circular definitions.
factory :appointment do
transient do
duration { rand(15..180).minutes }
end
# Circular
start_at { end_at + duration }
end_at { start_at - duration }
end
We need to use a callback to avoid the circular dependency.
factory :appointment do
transient do
duration { rand(15..180).minutes }
end
after(:build) do |appointment, evaluator|
case
when appointment.start_at && appointment.end_at
# The user set both, leave them be.
when appointment.start_at
# The user set only the start_at.
appointment.end_at ||= appointment.start_at + evaluator.duration
when appointment.end_at
# The user set only the end_at.
appointment.start_at ||= appointment.end_at - evaluator.duration
else
# The user set neither.
appointment.start_at = Faker::Time.between(from: 2.years.ago, to: 2.years.since)
appointment.end_at = appointment.start_at + evaluator.duration
end
end
end
p build(:appointment)
p build(:appointment, duration: 1.hour, start_at: 1.year.ago)
p build(:appointment, duration: 1.hour, end_at: 1.year.since)
p build(:appointment, start_at: 1.year.ago, end_at: 1.year.since)
Finally, if you're using Postgres, you can merge start_at and end_at into a single range column. This uses Postgres's tstzrange type which Rails will turn into a Range between two Times. This can be much easier to work with than start and end timestamps.
factory :appointment do
transient do
duration { rand(15..180).minutes }
end
timespan do
start_time = Faker::Time.between(from: 2.years.ago, to: 2.years.since)
end_time = start_time + duration
(start_time..end_time)
end
end
p FactoryBot.build(:appointment)
p FactoryBot.build(:appointment, duration: 1.hour)
p FactoryBot.build(:appointment, timespan: (1.year.ago..Time.current))