loading
Generated 2024-06-14T14:56:22+00:00

All Files ( 99.37% covered at 4.84 hits/line )

35 files in total.
791 relevant lines, 786 lines covered and 5 lines missed. ( 99.37% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app.rb 95.45 % 31 22 21 1 0.95
app/controllers/appointments_controller.rb 96.88 % 66 32 31 1 8.22
app/controllers/appointments_controller/params_schema.rb 100.00 % 41 14 14 0 1.00
app/controllers/doctors_controller.rb 100.00 % 16 9 9 0 2.00
app/models/appointment.rb 92.86 % 37 14 13 1 8.07
app/models/appointment/overlapping_scope.rb 100.00 % 49 19 19 0 20.00
app/models/appointment/presenter.rb 100.00 % 24 10 10 0 8.10
app/models/auth_token.rb 100.00 % 14 8 8 0 8.75
app/models/doctor.rb 100.00 % 71 19 19 0 18.89
app/models/doctor/presenter.rb 100.00 % 13 4 4 0 1.50
app/models/organization.rb 100.00 % 26 8 8 0 6.25
app/services/api_key_service.rb 100.00 % 16 10 10 0 2.40
app/services/authentication.rb 100.00 % 36 20 20 0 9.00
app/services/available_slot_service.rb 100.00 % 116 46 46 0 10.30
app/services/create_appointments_service.rb 100.00 % 43 24 24 0 6.88
config/router.rb 96.55 % 63 29 28 1 5.83
spec/config/router_spec.rb 100.00 % 264 132 132 0 2.49
spec/controllers/appointments_controller/params_schema_spec.rb 100.00 % 98 47 47 0 1.34
spec/controllers/appointments_controller_spec.rb 100.00 % 71 36 36 0 1.25
spec/controllers/doctors_controller_spec.rb 100.00 % 26 14 14 0 1.50
spec/factories/appointment.rb 100.00 % 9 6 6 0 4.83
spec/factories/doctor.rb 100.00 % 11 8 8 0 23.50
spec/factories/organization.rb 100.00 % 7 5 5 0 22.40
spec/models/appointment/overlapping_scope_spec.rb 100.00 % 60 24 24 0 1.75
spec/models/appointment/presenter_spec.rb 100.00 % 31 15 15 0 1.73
spec/models/appointment_spec.rb 80.00 % 27 5 4 1 0.80
spec/models/auth_token_spec.rb 100.00 % 19 11 11 0 1.36
spec/models/doctor/presenter_spec.rb 100.00 % 14 8 8 0 1.38
spec/models/doctor_spec.rb 100.00 % 112 50 50 0 2.64
spec/models/organization_spec.rb 100.00 % 41 16 16 0 1.63
spec/services/api_key_service_spec.rb 100.00 % 22 12 12 0 1.67
spec/services/authentication_spec.rb 100.00 % 66 37 37 0 2.41
spec/services/available_slot_service_spec.rb 100.00 % 98 35 35 0 1.49
spec/services/create_appointments_service_spec.rb 100.00 % 59 29 29 0 1.59
spec/test_helper.rb 100.00 % 22 13 13 0 14.85

app.rb

95.45% lines covered

22 relevant lines. 21 lines covered and 1 lines missed.
    
  1. 1 require "dotenv"
  2. 1 Dotenv.load
  3. 1 require "sinatra"
  4. 1 require "sinatra/activerecord"
  5. 1 require "sinatra/json"
  6. 1 require "yaml"
  7. 1 require "erb"
  8. 1 require "zeitwerk"
  9. 1 require "securerandom"
  10. 1 require "pry" if ENV["RACK_ENV"] != "production"
  11. 1 loader = Zeitwerk::Loader.new
  12. 1 loader.push_dir(File.join(__dir__, "app/models"))
  13. 1 loader.push_dir(File.join(__dir__, "app/controllers"))
  14. 1 loader.push_dir(File.join(__dir__, "app/services"))
  15. 1 loader.push_dir(File.join(__dir__, "config"))
  16. 1 loader.setup
  17. 1 loader.eager_load
  18. # Load database configurations based on the environment.
  19. # By default, Sinatra's environment is set to development.
  20. # change `ENV['RACK_ENV']` to different ones when needed.
  21. 1 erb_content = ERB.new(File.read("config/database.yml")).result
  22. 1 db_config = YAML.safe_load(erb_content)[Sinatra::Base.environment.to_s]
  23. 1 set :database, db_config
  24. # Start the application if this file is executed directly.
  25. 1 if __FILE__ == $0
  26. Router.run!
  27. end

app/controllers/appointments_controller.rb

96.88% lines covered

32 relevant lines. 31 lines covered and 1 lines missed.
    
  1. 1 class AppointmentsController
  2. 1 def self.call(action, params)
  3. 28 params = ParamsSchema.call(params)
  4. 28 if params.errors.any?
  5. 1 {error: params.errors.to_h}
  6. else
  7. 27 doctor = Doctor.find_by(id: params[:doctor_id])
  8. 27 return {error: "Doctor not found"} unless doctor
  9. 27 new(doctor, params.to_h).public_send(action)
  10. end
  11. end
  12. 1 def initialize(doctor, params)
  13. 27 self.doctor = doctor
  14. 27 self.params = params
  15. end
  16. 1 def index
  17. 5 AvailableSlotService.call(
  18. doctor,
  19. start_date: params[:start_date],
  20. end_date: params[:end_date]
  21. )
  22. end
  23. 1 def create
  24. # REFACTOR: Move this check logic to schema validation level
  25. 11 if params[:appointments].present? || params[:appointment].present?
  26. 7 CreateAppointmentsService.call(doctor, params)
  27. else
  28. 4 [400, {error: "No appointments to create"}]
  29. end
  30. end
  31. 1 def update
  32. # REFACTOR: Move this to similar service as CreateAppointmentsService or combine them in one
  33. 6 appointment = doctor.appointments.find_by(id: params[:appointment_id])
  34. 6 if params[:appointment].present? && appointment&.update(params[:appointment])
  35. 3 [200, Appointment::Presenter.new(appointment).to_h]
  36. else
  37. 3 [400, update_error(appointment)]
  38. end
  39. end
  40. 1 def delete
  41. # REFACTOR: Move this to similar service as CreateAppointmentsService or combine them in one
  42. 4 appointment = doctor.appointments.find_by(id: params[:appointment_id])
  43. 4 return 204 if appointment&.destroy
  44. 2 [400, {error: "Appointment not found"}]
  45. end
  46. 1 private
  47. 1 attr_accessor :doctor, :params
  48. # REFACTOR: Use null object pattern for appointment and this method can be
  49. # removed
  50. 1 def update_error(appointment)
  51. 3 if appointment&.errors&.any?
  52. {error: appointment.errors.messages}
  53. else
  54. 3 {error: "Appointment not found"}
  55. end
  56. end
  57. end

app/controllers/appointments_controller/params_schema.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. 1 require "dry-schema"
  2. 1 class AppointmentsController
  3. 1 ParamsSchema = Dry::Schema.Params do
  4. 1 required(:doctor_id).filled(:integer)
  5. 1 optional(:start_date).filled(:date)
  6. 1 optional(:end_date).filled(:date)
  7. 1 optional(:appointment_id).filled(:integer)
  8. 1 optional(:appointments).value(:array).each do
  9. 1 schema do
  10. 1 required(:patient_name).filled(:string)
  11. 1 required(:start_time).filled(:string)
  12. end
  13. end
  14. 1 optional(:appointment).schema do
  15. 1 required(:patient_name).filled(:string)
  16. 1 required(:start_time).filled(:string)
  17. end
  18. end
  19. # IndexSchema = Dry::Schema.Params do
  20. # required(:doctor_id).filled(:integer)
  21. # optional(:start_date).filled(:date)
  22. # optional(:end_date).filled(:date)
  23. # end
  24. # CreateSchema = Dry::Schema.Params do
  25. # required(:doctor_id).filled(:integer)
  26. # optional(:appointment_id).filled(:integer)
  27. # optional(:appointments).value(:array).each do
  28. # schema do
  29. # required(:patient_name).filled(:string)
  30. # required(:start_time).filled(:string)
  31. # end
  32. # end
  33. # optional(:appointment).schema do
  34. # required(:patient_name).filled(:string)
  35. # required(:start_time).filled(:string)
  36. # end
  37. # end
  38. end

app/controllers/doctors_controller.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. 1 class DoctorsController
  2. 1 def self.call(method, params)
  3. 4 doctor = Doctor.where(id: params[:doctor_id]).first_or_initialize
  4. 4 new(doctor).public_send(method)
  5. end
  6. 1 def initialize(doctor)
  7. 4 self.doctor = Doctor::Presenter.new(doctor)
  8. end
  9. 1 delegate :availability, to: :doctor
  10. 1 private
  11. 1 attr_accessor :doctor
  12. end

app/models/appointment.rb

92.86% lines covered

14 relevant lines. 13 lines covered and 1 lines missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: appointments
  4. #
  5. # id :integer not null, primary key
  6. # doctor_id :integer
  7. # patient_name :string
  8. # start_time :datetime
  9. # created_at :datetime not null
  10. # updated_at :datetime not null
  11. #
  12. # models/appointment.rb
  13. 1 class Appointment < ActiveRecord::Base
  14. 1 belongs_to :doctor
  15. 1 scope :overlapping, OverlappingScope
  16. 1 validates :patient_name, :start_time, presence: true
  17. 1 validate :start_time_within_working_hours
  18. 1 validate :no_double_booking
  19. 1 private
  20. 1 def start_time_within_working_hours
  21. 34 unless start_time && doctor&.appointment_within_working_hours?(start_time)
  22. 2 errors.add(:start_time, "must be within the doctor's working hours")
  23. end
  24. end
  25. 1 def no_double_booking
  26. 34 overlapping_appointments = self.class.overlapping(self)
  27. 34 if overlapping_appointments.exists?
  28. errors.add(:start_time, "has overlapping appointments")
  29. end
  30. end
  31. end

app/models/appointment/overlapping_scope.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. 1 class Appointment::OverlappingScope
  2. 1 def self.call(appointment)
  3. 34 new(
  4. appointment_id: appointment.id,
  5. doctor_id: appointment.doctor_id,
  6. start_time: appointment.start_time,
  7. slot_duration: appointment.doctor.slot_duration,
  8. break_duration: appointment.doctor.break_duration
  9. ).call
  10. end
  11. 1 def initialize(appointment_id: nil, doctor_id: nil, start_time: nil, slot_duration: nil, break_duration: nil)
  12. 37 self.id = appointment_id
  13. 37 self.doctor_id = doctor_id
  14. 37 self.start_time = start_time
  15. 37 self.slot_duration = slot_duration
  16. 37 self.break_duration = break_duration
  17. end
  18. 1 def call
  19. 38 module_parent
  20. .where(doctor_id: doctor_id)
  21. .where(sql)
  22. .where.not(id: id)
  23. end
  24. 1 delegate :module_parent, to: :class
  25. 1 private
  26. 1 attr_accessor :id, :doctor_id, :start_time, :slot_duration, :break_duration, :options
  27. 1 def sql
  28. 38 format(
  29. 38 <<-SQL,
  30. (start_time <= '%{slot_end_time}' AND datetime(start_time, '+%{slot_duration} minutes') > '%{start_time}') OR
  31. ('%{start_time}' < datetime('%{slot_end_time}', '+%{break_duration} minutes') AND '%{start_time}' >= '%{slot_end_time}')
  32. SQL
  33. slot_end_time: slot_end_time.strftime("%Y-%m-%d %H:%M:%S"),
  34. start_time: start_time.strftime("%Y-%m-%d %H:%M:%S"),
  35. slot_duration: slot_duration,
  36. break_duration: break_duration
  37. )
  38. end
  39. 1 def slot_end_time
  40. 38 @slot_end_time ||= (start_time + slot_duration.minutes)
  41. end
  42. end

app/models/appointment/presenter.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. 1 class Appointment::Presenter < SimpleDelegator
  2. 1 def formatted_start_time
  3. 19 start_time.strftime("%d-%m-%Y %H:%M")
  4. end
  5. 1 def formatted_end_time
  6. 19 calc_end_time.strftime("%d-%m-%Y %H:%M")
  7. end
  8. 1 def to_h
  9. {
  10. 18 id: id,
  11. patient_name: patient_name,
  12. start_time: formatted_start_time,
  13. end_time: formatted_end_time
  14. }
  15. end
  16. 1 private
  17. 1 def calc_end_time
  18. 19 start_time + doctor.slot_duration.minutes
  19. end
  20. end

app/models/auth_token.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. 1 require "jwt"
  2. 1 class AuthToken
  3. 1 SECRET_KEY = ENV["SECRET_KEY"]
  4. 1 ALGORITHM = "HS256"
  5. 1 def self.issue_token(payload)
  6. 35 JWT.encode(payload, SECRET_KEY, ALGORITHM)
  7. end
  8. 1 def self.decode_token(token)
  9. 29 JWT.decode(token, SECRET_KEY, true, {algorithm: ALGORITHM}).first
  10. end
  11. end

app/models/doctor.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: doctors
  4. #
  5. # id :integer not null, primary key
  6. # name :string
  7. # work_start_time :time
  8. # work_end_time :time
  9. # slot_duration :integer
  10. # appointment_slot_limit :integer
  11. # break_duration :integer
  12. # created_at :datetime not null
  13. # updated_at :datetime not null
  14. #
  15. 1 class Doctor < ActiveRecord::Base
  16. 1 has_many :appointments
  17. # Validates the presence of name, work_start_time, work_end_time, slot_duration, and break_duration.
  18. 1 validates :name, :work_start_time, :work_end_time, :slot_duration,
  19. :break_duration, presence: true
  20. # Validates that work_start_time is before work_end_time.
  21. 1 validate :work_start_time_before_work_end_time
  22. # Validates that slot_duration is shorter than the doctor's working hours.
  23. 1 validate :slot_duration_is_shorter_than_working_hours
  24. # Checks if the appointment time is within the doctor's working hours.
  25. #
  26. # @param appointment_time [Time] the time of the appointment
  27. # @return [Boolean] true if the appointment is within working hours, false otherwise
  28. 1 def appointment_within_working_hours?(appointment_time)
  29. 36 appt_time_as_time = Time.new(
  30. work_start_time.year,
  31. work_start_time.month,
  32. work_start_time.day,
  33. appointment_time.hour,
  34. appointment_time.min,
  35. appointment_time.sec,
  36. appointment_time.zone
  37. )
  38. # Calculate appointment end time using the doctor's slot_duration
  39. 36 appointment_end_time = appt_time_as_time + slot_duration.minutes
  40. # Check if both start and end times are within doctor's working hours.
  41. 36 appt_time_as_time.between?(work_start_time, work_end_time) &&
  42. appointment_end_time.between?(work_start_time, work_end_time)
  43. end
  44. 1 private
  45. # Validates that work_start_time is before work_end_time.
  46. 1 def work_start_time_before_work_end_time
  47. 49 return unless work_start_time && work_end_time
  48. 47 if work_start_time >= work_end_time
  49. 2 errors.add(:work_start_time, "must be before work end time")
  50. end
  51. end
  52. # Validates that slot_duration is shorter than the doctor's working hours.
  53. 1 def slot_duration_is_shorter_than_working_hours
  54. 49 return unless work_start_time && work_end_time && slot_duration
  55. 46 work_duration = (work_end_time - work_start_time) / 60
  56. 46 if slot_duration >= work_duration
  57. 3 errors.add(:slot_duration, "must be shorter than working hours")
  58. end
  59. end
  60. end

app/models/doctor/presenter.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. 1 require "delegate"
  2. 1 class Doctor::Presenter < SimpleDelegator
  3. 1 def availability
  4. {
  5. 3 doctor_id: id,
  6. working_hours: {
  7. start_time: work_start_time.to_formatted_s(:time),
  8. end_time: work_end_time.to_formatted_s(:time)
  9. }
  10. }
  11. end
  12. end

app/models/organization.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: organizations
  4. #
  5. # id :integer not null, primary key
  6. # name :string
  7. # email :string
  8. # api_key_digest :string
  9. # token :string
  10. # created_at :datetime not null
  11. # updated_at :datetime not null
  12. #
  13. 1 require "bcrypt"
  14. 1 class Organization < ActiveRecord::Base
  15. 1 validates :email, presence: true, uniqueness: true
  16. 1 validates :name, :api_key_digest, presence: true
  17. 1 def api_key=(new_api_key)
  18. 41 self.api_key_digest = BCrypt::Password.create(new_api_key)
  19. end
  20. 1 def authenticate_api_key(test_api_key)
  21. 3 BCrypt::Password.new(api_key_digest) == test_api_key
  22. end
  23. end

app/services/api_key_service.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. 1 class ApiKeyService
  2. 1 def generate_api_key
  3. 11 SecureRandom.hex(32)
  4. end
  5. 1 def rotate_api_key(organization)
  6. 2 api_key = generate_api_key
  7. 2 organization.api_key = api_key
  8. 2 organization.save!
  9. 2 api_key
  10. end
  11. 1 private
  12. 1 attr_accessor :organization
  13. end

app/services/authentication.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. 1 module Authentication
  2. 1 attr_accessor :errors
  3. 1 def exchange_key
  4. 7 organization = Organization.find_by(email: params[:email])
  5. 7 error = {error: "Incorrect email or api_key"}
  6. 7 return error unless organization&.authenticate_api_key(params[:api_key])
  7. 3 {token: AuthToken.issue_token(organization_id: organization.id)}
  8. end
  9. 1 def current_organization
  10. 2 @current_organization ||= Organization.find_by(id: token["organization_id"]) if authenticated?
  11. end
  12. 1 def authenticated?
  13. 29 self.errors = []
  14. 29 validate_token(request.env["Authorization"] || request.env["HTTP_AUTHORIZATION"])
  15. 29 errors.empty?
  16. end
  17. 1 private
  18. 1 attr_accessor :token
  19. 1 def validate_token(token)
  20. 29 case token
  21. when /^Bearer /
  22. 28 self.token = AuthToken.decode_token(token.gsub(/^Bearer /, ""))
  23. else
  24. 1 errors << ["Token must be a Bearer token not: #{token}"] # TODO: add to locale
  25. end
  26. rescue JWT::DecodeError => e
  27. 1 errors << "Invalid token " + e.message # TODO: add to locale
  28. end
  29. end

app/services/available_slot_service.rb

100.0% lines covered

46 relevant lines. 46 lines covered and 0 lines missed.
    
  1. 1 class AvailableSlotService
  2. 1 def self.call(*args, **kwargs)
  3. 5 new(*args, **kwargs).call
  4. end
  5. 1 def initialize(doctor, start_date: nil, end_date: nil)
  6. 8 self.doctor = doctor
  7. 8 self.start_date = start_date || Date.current
  8. 8 self.end_date = find_end_date(end_date, start_date)
  9. 8 self.result = {}
  10. end
  11. # Returns a hash containing available slots for a given doctor between start_time and end_time
  12. #
  13. # @return [Hash] A hash containing available slots for a given doctor between start_time and end_time
  14. 1 def call
  15. 8 return result if result.present?
  16. # TODO: Think of a better way to do this like grabbing all appointments in one query and maybe using group_by to group them by date
  17. 8 (start_date..end_date).each do |date|
  18. 31 working_hours = [(convert_to_time(date, doctor.work_start_time)..convert_to_time(date, doctor.work_end_time))]
  19. 31 appointments = find_appointments_for(date)
  20. 31 if appointments.any?
  21. 1 appointments.each do |appointment|
  22. 2 occupied_range = (appointment.start_time..(appointment.start_time + doctor.slot_duration.minutes + doctor.break_duration.minutes))
  23. 2 working_hours[-1] = subtract_ranges(working_hours[-1], occupied_range)
  24. 2 working_hours.flatten!
  25. end
  26. 4 working_hours.map { |range| format_range(range) }
  27. end
  28. 64 result[date.strftime("%d-%m-%Y")] = working_hours.map { |range| format_range(range) }
  29. end
  30. 8 result
  31. end
  32. 1 private
  33. 1 attr_accessor :doctor, :start_date, :end_date, :result
  34. 1 def find_end_date(end_date, start_date)
  35. 8 return end_date if end_date.present?
  36. 3 (start_date || Date.current) + 6.days
  37. end
  38. # Converts a given date and time to a Time object
  39. #
  40. # @param date [Date] The date to convert
  41. # @param time [Time] The time to convert
  42. # @return [Time] The converted Time object
  43. 1 def convert_to_time(date, time)
  44. 62 Time.new(date.year, date.month, date.day, time.hour, time.min, 0)
  45. end
  46. # Finds appointments for a given date
  47. #
  48. # @param date [Date] The date to find appointments for
  49. # @return [ActiveRecord::Relation] An ActiveRecord relation containing appointments for the given date
  50. 1 def find_appointments_for(date)
  51. 31 @doctor.appointments.where("DATE(start_time) = ?", date).order(:start_time)
  52. end
  53. # Subtracts an occupied range from a given range
  54. #
  55. # @param range [Range] The range to subtract from
  56. # @param occupied [Range] The occupied range to subtract
  57. # @return [Array<Range>] An array of ranges representing the available slots
  58. 1 def subtract_ranges(range, occupied)
  59. 2 ranges = []
  60. 2 if range.include?(occupied)
  61. 2 ranges += divide_range(range, occupied)
  62. end
  63. 2 ranges
  64. end
  65. # Divides a given range into two ranges separated by a gap
  66. #
  67. # @param range [Range] The range to divide
  68. # @param gap [Range] The gap to divide the range by
  69. # @return [Array<Range>] An array of ranges representing the divided range
  70. 1 def divide_range(range, gap)
  71. 2 ranges = []
  72. # Check if there is possibility to add appointment before gap
  73. 2 if gap.begin - range.begin >= (doctor.slot_duration.minutes + doctor.break_duration.minutes)
  74. 2 ranges << (range.begin..gap.begin)
  75. end
  76. # Check if there is possibility to add appointment after gap
  77. 2 if range.end - gap.end >= doctor.slot_duration.minutes
  78. 2 ranges << (gap.end..range.end)
  79. end
  80. 2 ranges
  81. end
  82. # Formats a given range to a string
  83. #
  84. # @param range [Range] The range to format
  85. # @return [String] A string representing the formatted range
  86. 1 def format_range(range)
  87. 36 "#{format_time(range.begin)} - #{format_time(range.end)}".squeeze(" ")
  88. end
  89. # Formats a given time to a string
  90. #
  91. # @param time [Time] The time to format
  92. # @return [String] A string representing the formatted time
  93. 1 def format_time(time)
  94. 72 time.strftime("%I:%M %p").strip
  95. end
  96. end

app/services/create_appointments_service.rb

100.0% lines covered

24 relevant lines. 24 lines covered and 0 lines missed.
    
  1. 1 class CreateAppointmentsService
  2. 1 def self.call(*args)
  3. 11 new(*args).call
  4. end
  5. 1 def initialize(doctor, params)
  6. 11 self.doctor = doctor
  7. 11 self.params = params
  8. end
  9. 1 def call
  10. 11 self.result = if params[:appointments].present?
  11. 4 params[:appointments].map do |appointment_params|
  12. 8 create_appointment(appointment_params)
  13. end
  14. else
  15. 7 [create_appointment(params[:appointment])]
  16. end
  17. 11 result
  18. end
  19. 1 def result
  20. 26 if @result&.any? { _1[:errors] }
  21. 2 [400, @result]
  22. else
  23. 9 [201, @result]
  24. end
  25. end
  26. 1 private
  27. 1 attr_accessor :doctor, :params
  28. 1 attr_writer :result
  29. 1 def create_appointment(appointment_params)
  30. 15 appointment = doctor.appointments.build(appointment_params)
  31. 15 if appointment.save
  32. 13 Appointment::Presenter.new(appointment).to_h
  33. else
  34. 2 {errors: appointment.errors.messages}
  35. end
  36. end
  37. end

config/router.rb

96.55% lines covered

29 relevant lines. 28 lines covered and 1 lines missed.
    
  1. # Endpoint to find a doctors working hours
  2. # Endpoint to book a doctors open slot
  3. # Endpoint to update a doctors appointment
  4. # Endpoint to delete a doctors appointment
  5. # Endpoint to view a doctors availability
  6. 1 class Router < Sinatra::Base
  7. 1 helpers Authentication
  8. 1 before do
  9. 31 content_type :json
  10. 31 unless request.accept? "application/json"
  11. halt 415, {error: "Server only supports application/json"}.to_json
  12. end
  13. end
  14. 1 before "/api/*" do
  15. 27 return if authenticated?
  16. 1 halt 403, {error: errors.join(" ")}.to_json
  17. end
  18. 1 get "/api/v1" do
  19. 1 {message: "pong"}.to_json
  20. end
  21. 1 post "/exchange_key" do
  22. 4 exchange_key.to_json
  23. end
  24. 1 get "/api/v1/doctors/:doctor_id/hours" do
  25. # REFACTOR: This should be hours, not availability
  26. 2 DoctorsController.call(:availability, params).to_json
  27. end
  28. 1 get "/api/v1/doctors/:doctor_id/availability" do
  29. # REFACTOR: This should be availability, not index
  30. 4 AppointmentsController.call(:index, params).to_json
  31. end
  32. # TODO: Add a route to view a doctor's appointments
  33. # get "/api/v1/doctors/:doctor_id/appointments" do
  34. # AppointmentsController.call(:index, params).to_json
  35. # end
  36. 1 post "/api/v1/doctors/:doctor_id/appointments" do
  37. # TODO: right now this is using form params, but it should be using json params
  38. 9 code, response = AppointmentsController.call(:create, params)
  39. 9 status code
  40. 9 response.to_json
  41. end
  42. 1 put "/api/v1/doctors/:doctor_id/appointments/:appointment_id" do
  43. 6 code, response = AppointmentsController.call(:update, params)
  44. 6 status code
  45. 6 response.to_json
  46. end
  47. 1 delete "/api/v1/doctors/:doctor_id/appointments/:appointment_id" do
  48. 4 code, response = AppointmentsController.call(:delete, params)
  49. 4 status code
  50. 4 response&.to_json
  51. end
  52. end

spec/config/router_spec.rb

100.0% lines covered

132 relevant lines. 132 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 require "rack/test"
  3. 1 RSpec.describe Router do
  4. 1 include Rack::Test::Methods
  5. 27 let(:organization) { create(:organization) }
  6. 27 let(:token) { AuthToken.issue_token(organization_id: organization.id) }
  7. 27 let(:headers) { {"Authorization" => "Bearer #{token}"} }
  8. 1 def app
  9. 31 Router
  10. end
  11. 1 describe "GET /api/" do
  12. 1 context "when authenticated" do
  13. 1 it "responds with 200" do
  14. 1 get "/api/v1", nil, headers
  15. 1 expect(last_response).to be_ok
  16. end
  17. end
  18. 1 context "when not authenticated" do
  19. 2 let(:headers) { {} }
  20. 1 it "responds with 403" do
  21. 1 get "/api/v1", nil, headers
  22. 1 expect(last_response.status).to eq(403)
  23. end
  24. end
  25. end
  26. 1 describe "POST /exchange_key" do
  27. 3 let(:organization) { create(:organization, api_key: api_key) }
  28. 5 let(:api_key) { ApiKeyService.new.generate_api_key }
  29. 1 before do
  30. 4 post "/exchange_key", params
  31. end
  32. 1 context "when valid params" do
  33. 3 let(:params) { {email: organization.email, api_key: api_key} }
  34. 1 it "responds with 200" do
  35. 1 expect(last_response).to be_ok
  36. end
  37. 1 it "returns a token" do
  38. 1 expect(JSON.parse(last_response.body)).to include("token")
  39. end
  40. end
  41. 1 context "when invalid params" do
  42. 3 let(:params) { {email: "invalid", api_key: api_key} }
  43. 1 it "responds with 200" do
  44. 1 expect(last_response).to be_ok
  45. end
  46. 1 it "returns a token" do
  47. 1 expect(JSON.parse(last_response.body)).to include("error")
  48. end
  49. end
  50. end
  51. 1 describe "GET /api/v1/doctors/:doctor_id/hours" do
  52. 3 let(:doctor) { create(:doctor) }
  53. 1 before do
  54. 2 get "/api/v1/doctors/#{doctor.id}/hours", nil, headers
  55. end
  56. 1 it "responds with 200" do
  57. 1 expect(last_response).to be_ok
  58. end
  59. 1 it "returns the doctor's availability" do
  60. 1 expect(JSON.parse(last_response.body)).to include("working_hours")
  61. end
  62. end
  63. 1 describe "GET /api/v1/doctors/:doctor_id/availability" do
  64. 5 let(:doctor) { create(:doctor) }
  65. 1 before do
  66. 4 get "/api/v1/doctors/#{doctor.id}/availability", query_params, headers
  67. end
  68. 1 context "with start and end date" do
  69. 3 let(:query_params) { {start_date: "2019-01-01", end_date: "2019-01-2"} }
  70. 1 it "responds with 200" do
  71. 1 expect(last_response).to be_ok
  72. end
  73. 1 it "returns the doctor's availability" do
  74. 1 expect(JSON.parse(last_response.body)).to include("01-01-2019", "02-01-2019")
  75. 1 expect(JSON.parse(last_response.body)).not_to include("03-01-2019")
  76. end
  77. end
  78. 1 context "with start date only" do
  79. 3 let(:query_params) { {start_date: "2019-01-01"} }
  80. 1 it "responds with 200" do
  81. 1 expect(last_response).to be_ok
  82. end
  83. 1 it "returns the doctor's availability" do
  84. 1 expect(JSON.parse(last_response.body)).to include("01-01-2019", "07-01-2019")
  85. end
  86. end
  87. end
  88. 1 describe "POST /api/v1/doctors/:doctor_id/appointments" do
  89. 10 let(:doctor) { create(:doctor) }
  90. 1 before do
  91. 9 post "/api/v1/doctors/#{doctor.id}/appointments", params, headers
  92. end
  93. 1 context "with valid params" do
  94. 1 context "with multiple appointments" do
  95. 1 let(:params) do
  96. {
  97. 3 appointments: [
  98. {
  99. patient_name: "John Doe",
  100. start_time: "2019-01-01 09:00 AM UTC"
  101. },
  102. {
  103. patient_name: "Jane Doe",
  104. start_time: "2019-01-01 10:00 AM UTC"
  105. }
  106. ]
  107. }
  108. end
  109. 1 it "responds with 201" do
  110. 1 expect(last_response).to be_created
  111. end
  112. 1 it "creates the appointments" do
  113. 1 expect(Appointment.count).to eq(2)
  114. end
  115. 1 it "returns the appointments" do
  116. 3 expect(JSON.parse(last_response.body).map { _1["patient_name"] }).to include("John Doe", "Jane Doe")
  117. end
  118. end
  119. 1 context "with a single appointment" do
  120. 1 let(:params) do
  121. 3 {appointment: {patient_name: "John Doe", start_time: "2019-01-01 09:00 AM UTC"}}
  122. end
  123. 1 it "responds with 201" do
  124. 1 expect(last_response).to be_created
  125. end
  126. 1 it "creates the appointment" do
  127. 1 expect(Appointment.count).to eq(1)
  128. end
  129. 1 it "returns the appointment" do
  130. 1 expect(JSON.parse(last_response.body).first.values).to include("John Doe")
  131. end
  132. end
  133. end
  134. 1 context "with invalid params" do
  135. 1 let(:params) do
  136. 3 {}
  137. end
  138. 1 it "responds with 400" do
  139. 1 expect(last_response.status).to eq(400)
  140. end
  141. 1 it "returns the error" do
  142. 1 expect(JSON.parse(last_response.body)).to include("error")
  143. end
  144. 1 it "does not create the appointment" do
  145. 1 expect(Appointment.count).to eq(0)
  146. end
  147. end
  148. end
  149. 1 describe "PUT /api/v1/doctors/:doctor_id/appointments/:appointment_id" do
  150. 7 let(:doctor) { create(:doctor) }
  151. 7 let(:appointment) { create(:appointment, doctor: doctor) }
  152. 1 before do
  153. 6 put "/api/v1/doctors/#{doctor.id}/appointments/#{appointment.id}", params, headers
  154. end
  155. 1 context "with valid params" do
  156. 1 let(:params) do
  157. 3 {appointment: {patient_name: "John Doe", start_time: "2019-01-01 09:00 AM UTC"}}
  158. end
  159. 1 it "responds with 200" do
  160. 1 expect(last_response).to be_ok
  161. end
  162. 1 it "updates the appointment" do
  163. 1 expect(appointment.reload.patient_name).to eq("John Doe")
  164. end
  165. 1 it "returns the appointment" do
  166. 1 expect(JSON.parse(last_response.body).values).to include("John Doe")
  167. end
  168. end
  169. 1 context "with invalid params" do
  170. 1 let(:params) do
  171. 3 {}
  172. end
  173. 1 it "responds with 400" do
  174. 1 expect(last_response.status).to eq(400)
  175. end
  176. 1 it "returns the errors" do
  177. 1 expect(JSON.parse(last_response.body)).to include("error" => "Appointment not found")
  178. end
  179. 1 it "does not update the appointment" do
  180. 1 expect(appointment.reload.patient_name).not_to eq("John Doe")
  181. end
  182. end
  183. end
  184. 1 describe "DELETE /api/v1/doctors/:doctor_id/appointments/:appointment_id" do
  185. 5 let(:doctor) { create(:doctor) }
  186. 5 let(:appointment) { create(:appointment, doctor: doctor) }
  187. 3 let(:appointment_id) { appointment.id }
  188. 1 before do
  189. 4 delete "/api/v1/doctors/#{doctor.id}/appointments/#{appointment_id}", nil, headers
  190. end
  191. 1 it "responds with 204" do
  192. 1 expect(last_response).to be_empty
  193. end
  194. 1 it "deletes the appointment" do
  195. 1 expect(Appointment.count).to eq(0)
  196. end
  197. 1 context "when appointment not found" do
  198. 3 let(:appointment_id) { appointment.id + 1 }
  199. 1 it "responds with 400" do
  200. 1 expect(last_response.status).to eq(400)
  201. end
  202. 1 it "returns the error" do
  203. 1 expect(JSON.parse(last_response.body)).to include("error" => "Appointment not found")
  204. end
  205. end
  206. end
  207. end

spec/controllers/appointments_controller/params_schema_spec.rb

100.0% lines covered

47 relevant lines. 47 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe AppointmentsController::ParamsSchema do
  3. 13 let(:schema) { described_class }
  4. 1 describe "doctor_id" do
  5. 1 it "is required" do
  6. 1 result = schema.call({})
  7. 1 expect(result.errors.to_h).to include(:doctor_id)
  8. end
  9. 1 it "must be an integer" do
  10. 1 result = schema.call(doctor_id: "not an integer")
  11. 1 expect(result.errors.to_h).to include(:doctor_id)
  12. end
  13. end
  14. 1 describe "appointment_id" do
  15. 1 it "is optional" do
  16. 1 result = schema.call(doctor_id: 1)
  17. 1 expect(result.errors.to_h).not_to include(:appointment_id)
  18. end
  19. 1 it "must be an integer" do
  20. 1 result = schema.call(doctor_id: 1, appointment_id: "not an integer")
  21. 1 expect(result.errors.to_h).to include(:appointment_id)
  22. end
  23. end
  24. 1 describe "appointments" do
  25. 1 it "is optional" do
  26. 1 result = schema.call(doctor_id: 1)
  27. 1 expect(result.errors.to_h).not_to include(:appointments)
  28. end
  29. 1 it "must be an array" do
  30. 1 result = schema.call(doctor_id: 1, appointments: "not an array")
  31. 1 expect(result.errors.to_h).to include(:appointments)
  32. end
  33. 1 it "can contain multiple appointment objects" do
  34. 1 result = schema.call(
  35. doctor_id: 1,
  36. appointments: [
  37. {patient_name: "John Doe", start_time: "2022-01-01T00:00:00Z"},
  38. {patient_name: "Jane Doe", start_time: "2022-01-02T00:00:00Z"}
  39. ]
  40. )
  41. 1 expect(result.errors.to_h).not_to include(:appointments)
  42. end
  43. 1 describe "appointment object" do
  44. 3 let(:appointment) { {patient_name: "John Doe", start_time: "2022-01-01T00:00:00Z"} }
  45. 1 it "must have a patient_name" do
  46. 1 result = schema.call(doctor_id: 1, appointments: [appointment.merge(patient_name: nil)])
  47. 1 expect(result.errors.to_h).to include(:appointments)
  48. end
  49. 1 it "must have a start_time" do
  50. 1 result = schema.call(doctor_id: 1, appointments: [appointment.merge(start_time: nil)])
  51. 1 expect(result.errors.to_h).to include(:appointments)
  52. end
  53. end
  54. end
  55. 1 describe "appointment" do
  56. 1 it "is optional" do
  57. 1 result = schema.call(doctor_id: 1)
  58. 1 expect(result.errors.to_h).not_to include(:appointment)
  59. end
  60. 1 describe "appointment object" do
  61. 3 let(:appointment) { {patient_name: "John Doe", start_time: "2022-01-01T00:00:00Z"} }
  62. 1 it "must have a patient_name" do
  63. 1 result = schema.call(doctor_id: 1, appointment: appointment.merge(patient_name: nil))
  64. 1 expect(result.errors.to_h).to include(:appointment)
  65. end
  66. 1 it "must have a start_time" do
  67. 1 result = schema.call(doctor_id: 1, appointment: appointment.merge(start_time: nil))
  68. 1 expect(result.errors.to_h).to include(:appointment)
  69. end
  70. end
  71. end
  72. end

spec/controllers/appointments_controller_spec.rb

100.0% lines covered

36 relevant lines. 36 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe AppointmentsController do
  3. 1 describe ".call" do
  4. 1 context "when doctor is not found" do
  5. 1 it "returns an error message" do
  6. 1 expect(AppointmentsController.call(:action, {})).to eq({error: {doctor_id: ["is missing"]}})
  7. end
  8. end
  9. 1 context "when doctor is found" do
  10. 2 let(:doctor) { instance_double(Doctor) }
  11. 2 let(:params) { {doctor_id: 1} }
  12. 1 before do
  13. 1 allow(Doctor).to receive(:find_by).with(id: params[:doctor_id]).and_return(doctor)
  14. 1 allow_any_instance_of(AppointmentsController).to receive(:index).and_return({})
  15. end
  16. 1 it "calls the action method on a new instance of the controller" do
  17. 1 expect_any_instance_of(AppointmentsController).to receive(:index)
  18. 1 AppointmentsController.call(:index, params)
  19. end
  20. end
  21. end
  22. 1 describe "#index" do
  23. 2 let(:doctor) { instance_double(Doctor) }
  24. 2 let(:params) { {doctor_id: 1} }
  25. 1 before do
  26. 1 allow(Doctor).to receive(:find_by).and_return(doctor)
  27. end
  28. 1 it "calls AvailableSlotService" do
  29. 1 expect(AvailableSlotService)
  30. .to receive(:call)
  31. .with(doctor, start_date: nil, end_date: nil)
  32. .and_return({})
  33. 1 AppointmentsController.call(:index, params)
  34. end
  35. end
  36. 1 describe "#create" do
  37. 3 let(:doctor) { instance_double(Doctor) }
  38. 1 before do
  39. 2 allow(Doctor).to receive(:find_by).and_return(doctor)
  40. end
  41. 1 context "when no appointments are passed" do
  42. 2 let(:params) { {doctor_id: 1} }
  43. 1 it "returns an error message" do
  44. 1 expect(AppointmentsController.call(:create, params)).to eq([400, {error: "No appointments to create"}])
  45. end
  46. end
  47. 1 context "when appointments are passed" do
  48. 2 let(:params) { {doctor_id: 1, appointments: [{patient_name: "John Doe", start_time: "01.01.2023 10:00"}]} }
  49. 1 it "calls CreateAppointmentService" do
  50. 1 expect(CreateAppointmentsService)
  51. .to receive(:call)
  52. .with(doctor, params)
  53. .and_return({})
  54. 1 AppointmentsController.call(:create, params)
  55. end
  56. end
  57. end
  58. end

spec/controllers/doctors_controller_spec.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe DoctorsController do
  3. 3 let(:doctor) { create(:doctor) }
  4. 1 describe ".call" do
  5. 3 let(:params) { {doctor_id: doctor.id} }
  6. 1 context "when method is :availability" do
  7. 2 let(:method) { :availability }
  8. 1 it "calls availability on the doctor presenter" do
  9. 1 expect_any_instance_of(Doctor::Presenter).to receive(:availability)
  10. 1 described_class.call(method, params)
  11. end
  12. end
  13. 1 context "when method is not :availability" do
  14. 2 let(:method) { :invalid_method }
  15. 1 it "raises a NoMethodError" do
  16. 2 expect { described_class.call(method, params) }.to raise_error(NoMethodError)
  17. end
  18. end
  19. end
  20. end

spec/factories/appointment.rb

100.0% lines covered

6 relevant lines. 6 lines covered and 0 lines missed.
    
  1. 1 require "faker"
  2. 1 FactoryBot.define do
  3. 1 factory :appointment do
  4. 11 start_time { DateTime.parse("01.01.2023 10:00 AM UTC") }
  5. 14 patient_name { Faker::Name.name }
  6. 1 doctor
  7. end
  8. end

spec/factories/doctor.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. 1 require "faker"
  2. 1 FactoryBot.define do
  3. 1 factory :doctor do
  4. 37 name { Faker::Name.name }
  5. 37 work_start_time { Time.parse("9:00 AM UTC") }
  6. 37 work_end_time { Time.parse("5:00 PM UTC") }
  7. 37 slot_duration { 55 }
  8. 37 break_duration { 5 }
  9. end
  10. end

spec/factories/organization.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. 1 FactoryBot.define do
  2. 1 factory :organization do
  3. 39 name { "Empire" }
  4. 37 sequence(:email) { |n| "email_#{n}@example.com" }
  5. 34 api_key { SecureRandom.hex(32) }
  6. end
  7. end

spec/models/appointment/overlapping_scope_spec.rb

100.0% lines covered

24 relevant lines. 24 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe Appointment::OverlappingScope do
  3. 3 let(:appointment_id) { 1 }
  4. 3 let(:doctor_id) { 2 }
  5. 4 let(:start_time) { DateTime.parse("04.10.2023 10:30 AM") }
  6. 4 let(:slot_duration) { 55 }
  7. 4 let(:break_duration) { 5 }
  8. 1 subject do
  9. 3 described_class.new(
  10. appointment_id: appointment_id,
  11. doctor_id: doctor_id,
  12. start_time: start_time,
  13. slot_duration: slot_duration,
  14. break_duration: break_duration
  15. )
  16. end
  17. 1 describe "#call" do
  18. 1 it "do not raise error" do
  19. 2 expect { subject.call }.not_to raise_error
  20. end
  21. 1 it "generates the correct SQL" do
  22. 1 expect(subject.call.to_sql.squish).to eq(
  23. 1 <<-SQL.squish
  24. SELECT "appointments".* FROM "appointments" WHERE "appointments"."doctor_id" = 2 AND ( (start_time <= '2023-10-04 11:25:00' AND datetime(start_time, '+55 minutes') > '2023-10-04 10:30:00') OR ('2023-10-04 10:30:00' < datetime('2023-10-04 11:25:00', '+5 minutes') AND '2023-10-04 10:30:00' >= '2023-10-04 11:25:00') ) AND "appointments"."id" != 1
  25. SQL
  26. ), subject.call.to_sql.squish
  27. end
  28. 1 context "with database records" do
  29. 2 let(:appointment_id) { nil }
  30. 2 let(:doctor_id) { doctor.id }
  31. 1 let(:doctor) do
  32. 1 Doctor.create!(
  33. name: "Dr. John Doe",
  34. work_start_time: Time.parse("9:00 AM").to_formatted_s(:db),
  35. work_end_time: Time.parse("5:00 PM").to_formatted_s(:db),
  36. slot_duration: 55,
  37. break_duration: 5
  38. )
  39. end
  40. 1 before do
  41. 1 Appointment.create!(
  42. doctor_id: doctor_id,
  43. start_time: DateTime.parse("04.10.2023 10:00 AM").to_formatted_s(:db),
  44. patient_name: "John Doe"
  45. )
  46. end
  47. 1 it "returns the correct records" do
  48. 1 expect(subject.call.count).to eq(1)
  49. end
  50. end
  51. end
  52. end

spec/models/appointment/presenter_spec.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe Appointment::Presenter do
  3. 4 subject { described_class.new(appointment) }
  4. 4 let(:appointment) { create(:appointment, start_time: DateTime.parse(start_time)) }
  5. 4 let(:start_time) { "12-10-2023 10:00" }
  6. 3 let(:end_time) { "12-10-2023 10:55" }
  7. 1 describe "#formatted_start_time" do
  8. 1 it "returns the formatted start time" do
  9. 1 expect(subject.formatted_start_time).to eq(start_time)
  10. end
  11. end
  12. 1 describe "#formatted_end_time" do
  13. 1 it "returns the formatted end time" do
  14. 1 expect(subject.formatted_end_time).to eq(end_time)
  15. end
  16. end
  17. 1 describe "#to_h" do
  18. 1 it "returns a hash of the appointment" do
  19. 1 expect(subject.to_h).to eq({
  20. id: appointment.id,
  21. patient_name: appointment.patient_name,
  22. start_time: start_time,
  23. end_time: end_time
  24. })
  25. end
  26. end
  27. end

spec/models/appointment_spec.rb

80.0% lines covered

5 relevant lines. 4 lines covered and 1 lines missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: appointments
  4. #
  5. # id :integer not null, primary key
  6. # doctor_id :integer
  7. # patient_name :string
  8. # start_time :datetime
  9. # created_at :datetime not null
  10. # updated_at :datetime not null
  11. #
  12. 1 require "test_helper"
  13. 1 RSpec.describe Appointment do
  14. 1 let(:doctor) do
  15. Doctor.create(
  16. name: "Dr. John Doe",
  17. work_start_time: Time.parse("9:00 AM"),
  18. work_end_time: Time.parse("5:00 PM"),
  19. slot_duration: 55,
  20. break_duration: 5
  21. )
  22. end
  23. 1 describe "validations" do
  24. end
  25. end

spec/models/auth_token_spec.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe AuthToken do
  3. 3 let(:payload) { {user_id: 1} }
  4. 3 let(:token) { described_class.issue_token(payload) }
  5. 1 describe ".issue_token" do
  6. 1 it "returns a JWT token" do
  7. 1 expect(token).to be_a(String)
  8. end
  9. end
  10. 1 describe ".decode_token" do
  11. 1 it "decodes a JWT token" do
  12. 1 decoded_payload = described_class.decode_token(token)
  13. 1 expect(decoded_payload).to eq(payload.stringify_keys)
  14. end
  15. end
  16. end

spec/models/doctor/presenter_spec.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe Doctor::Presenter do
  3. 2 let(:doctor) { build_stubbed(:doctor) }
  4. 2 let(:presenter) { described_class.new(doctor) }
  5. 1 describe "#availability" do
  6. 2 subject { presenter.availability }
  7. 1 it "returns the doctor's availability" do
  8. 1 expect(subject).to include(:working_hours)
  9. end
  10. end
  11. end

spec/models/doctor_spec.rb

100.0% lines covered

50 relevant lines. 50 lines covered and 0 lines missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: doctors
  4. #
  5. # id :integer not null, primary key
  6. # name :string
  7. # work_start_time :time
  8. # work_end_time :time
  9. # slot_duration :integer
  10. # appointment_slot_limit :integer
  11. # break_duration :integer
  12. # created_at :datetime not null
  13. # updated_at :datetime not null
  14. #
  15. 1 require "test_helper"
  16. 1 RSpec.describe Doctor do
  17. 9 let(:start_time) { Time.parse("9:00 AM UTC") }
  18. 9 let(:end_time) { Time.parse("5:00 PM UTC") }
  19. 10 let(:slot_duration) { 55 }
  20. 11 let(:break_duration) { 5 }
  21. 11 let(:name) { "Dr. John Doe" }
  22. 1 let(:doctor) do
  23. 11 Doctor.new(
  24. name: name,
  25. work_start_time: start_time,
  26. work_end_time: end_time,
  27. slot_duration: slot_duration,
  28. break_duration: break_duration
  29. )
  30. end
  31. 1 describe "validations" do
  32. 10 subject { doctor }
  33. 2 it { is_expected.to be_valid }
  34. 1 context "when name is not present" do
  35. 2 let(:name) { nil }
  36. 2 it { is_expected.to be_invalid }
  37. end
  38. 1 context "when work_start_time is missing" do
  39. 2 let(:start_time) { nil }
  40. 2 it { is_expected.to be_invalid }
  41. end
  42. 1 context "when work_end_time is missing" do
  43. 2 let(:end_time) { nil }
  44. 2 it { is_expected.to be_invalid }
  45. end
  46. 1 context "when slot_duration is missing" do
  47. 2 let(:slot_duration) { nil }
  48. 2 it { is_expected.to be_invalid }
  49. end
  50. 1 context "when break_duration is missing" do
  51. 2 let(:break_duration) { nil }
  52. 2 it { is_expected.to be_invalid }
  53. end
  54. 1 context "when work_start_time is after work_end_time" do
  55. 2 let(:start_time) { Time.parse("5:00 PM UTC") }
  56. 2 let(:end_time) { Time.parse("9:00 AM UTC") }
  57. 1 it "is invalid" do
  58. 1 expect(subject).to be_invalid
  59. end
  60. end
  61. 1 context "when work_start_time is equal to work_end_time" do
  62. 2 let(:start_time) { Time.parse("5:00 PM UTC") }
  63. 2 let(:end_time) { Time.parse("5:00 PM UTC") }
  64. 1 it "is invalid" do
  65. 1 expect(subject).to be_invalid
  66. end
  67. end
  68. 1 context "when slot_duration is greater than work duration" do
  69. 2 let(:slot_duration) { 9 * 60 }
  70. 1 it "is invalid" do
  71. 1 expect(subject).to be_invalid
  72. end
  73. end
  74. end
  75. 1 describe "#appointment_within_working_hours?" do
  76. 1 context "within working hours" do
  77. 2 let(:appointment_time) { Time.parse("11:00 AM UTC") }
  78. 1 it "returns true" do
  79. 1 expect(doctor.appointment_within_working_hours?(appointment_time)).to eq(true)
  80. end
  81. end
  82. 1 context "outside working hours" do
  83. 2 let(:appointment_time) { Time.parse("8:00 AM UTC") }
  84. 1 it "returns false" do
  85. 1 expect(doctor.appointment_within_working_hours?(appointment_time)).to eq(false)
  86. end
  87. end
  88. end
  89. end

spec/models/organization_spec.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: organizations
  4. #
  5. # id :integer not null, primary key
  6. # name :string
  7. # email :string
  8. # api_key_digest :string
  9. # token :string
  10. # created_at :datetime not null
  11. # updated_at :datetime not null
  12. #
  13. 1 require "test_helper"
  14. 1 RSpec.describe Organization do
  15. 1 describe "validations" do
  16. 2 subject { build(:organization) }
  17. 2 it { is_expected.to be_valid }
  18. 1 context "when email is not present" do
  19. 2 subject { build(:organization, email: nil) }
  20. 2 it { is_expected.to be_invalid }
  21. end
  22. 1 context "when email is not unique" do
  23. 2 let(:email) { "foo@example.com" }
  24. 2 subject { build(:organization, email: email) }
  25. 2 before { create(:organization, email: email) }
  26. 2 it { is_expected.to be_invalid }
  27. end
  28. 1 context "when name is not present" do
  29. 2 subject { build(:organization, name: nil) }
  30. 2 it { is_expected.to be_invalid }
  31. end
  32. end
  33. end

spec/services/api_key_service_spec.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe ApiKeyService do
  3. 4 subject { described_class.new }
  4. 3 let(:organization) { create(:organization) }
  5. 1 describe "#generate_api_key" do
  6. 1 it "returns a 32 character string" do
  7. 1 expect(subject.generate_api_key.length).to eq(64)
  8. end
  9. end
  10. 1 describe "#rotate_api_key" do
  11. 1 it "returns a 64 character string" do
  12. 1 expect(subject.rotate_api_key(organization).length).to eq(64)
  13. end
  14. 1 it "updates the organization's api_key" do
  15. 4 expect { subject.rotate_api_key(organization) }.to change { organization.reload.api_key_digest }
  16. end
  17. end
  18. end

spec/services/authentication_spec.rb

100.0% lines covered

37 relevant lines. 37 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe Authentication do
  3. 1 let(:described_module) do
  4. 5 Class.new do
  5. 5 attr_accessor :params, :request
  6. 5 def initialize(params: {}, request: {})
  7. 5 @params = params
  8. 5 @request = request
  9. end
  10. 5 include Authentication
  11. end.new(params: params, request: OpenStruct.new(env: headers))
  12. end
  13. 5 let(:organization) { create(:organization, api_key: api_key) }
  14. 5 let(:api_key) { ApiKeyService.new.generate_api_key }
  15. 5 let(:token) { AuthToken.issue_token({organization_id: organization.id}) }
  16. 6 let(:headers) { {"Authorization" => "Bearer #{token}"} }
  17. 1 describe "#exchange_key" do
  18. 4 subject { described_module.exchange_key }
  19. 1 context "when organization found" do
  20. 2 let(:params) { {api_key: api_key, email: organization.email} }
  21. 1 it "returns JWT token" do
  22. 1 expect(subject).to eq({token: token})
  23. end
  24. end
  25. 1 context "when api key is invalid" do
  26. 2 let(:params) { {api_key: "invalid"} }
  27. 1 it "returns nil" do
  28. 1 expect(subject).to eq({error: "Incorrect email or api_key"})
  29. end
  30. end
  31. 1 context "when organization not found" do
  32. 2 let(:params) { {api_key: api_key, email: "invalid"} }
  33. 1 it "returns nil" do
  34. 1 expect(subject).to eq({error: "Incorrect email or api_key"})
  35. end
  36. end
  37. end
  38. 1 describe "#current_organization" do
  39. 3 subject { described_module.current_organization }
  40. 3 let(:params) { {} }
  41. 1 context "when token is valid" do
  42. 1 it "returns organization" do
  43. 1 expect(subject).to eq(organization)
  44. end
  45. end
  46. 1 context "when token is invalid" do
  47. 2 let(:token) { "invalid" }
  48. 1 it "returns nil" do
  49. 1 expect(subject).to be_nil
  50. end
  51. end
  52. end
  53. end

spec/services/available_slot_service_spec.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe AvailableSlotService do
  3. 5 let(:start_date) { Date.parse("05-10-2023") }
  4. 4 let(:end_date) { Date.parse("06-10-2023") }
  5. 1 let(:doctor) do
  6. 4 Doctor.create!(
  7. name: "Dr. John Doe",
  8. work_start_time: Time.parse("9:00 AM UTC"),
  9. work_end_time: Time.parse("5:00 PM UTC"),
  10. slot_duration: 55,
  11. break_duration: 5
  12. )
  13. end
  14. 1 let(:service) do
  15. 4 described_class.new(doctor, start_date: start_date, end_date: end_date)
  16. end
  17. 1 describe ".call" do
  18. 1 before do
  19. 1 allow(AvailableSlotService).to receive(:new).and_return(service)
  20. end
  21. 1 it "calls new" do
  22. 1 described_class.call(doctor)
  23. 1 expect(AvailableSlotService).to have_received(:new)
  24. end
  25. end
  26. 1 describe "#call" do
  27. 4 subject { service.call }
  28. 1 context "with missing end_date" do
  29. 2 let(:end_date) { nil }
  30. 1 let(:result) do
  31. {
  32. 1 "05-10-2023" => ["09:00 AM - 05:00 PM"],
  33. "06-10-2023" => ["09:00 AM - 05:00 PM"],
  34. "07-10-2023" => ["09:00 AM - 05:00 PM"],
  35. "08-10-2023" => ["09:00 AM - 05:00 PM"],
  36. "09-10-2023" => ["09:00 AM - 05:00 PM"],
  37. "10-10-2023" => ["09:00 AM - 05:00 PM"],
  38. "11-10-2023" => ["09:00 AM - 05:00 PM"]
  39. }
  40. end
  41. 1 it "returns all slots for the next 7 days" do
  42. 1 expect(subject).to eq(result)
  43. end
  44. end
  45. 1 context "when there are no appointments" do
  46. 1 let(:result) do
  47. {
  48. 1 "05-10-2023" => ["09:00 AM - 05:00 PM"], # we cannot exceed 5:00 PM UTC because it would mean that the appointment would end after the doctor's working hours.
  49. "06-10-2023" => ["09:00 AM - 05:00 PM"]
  50. }
  51. end
  52. 1 it "returns all slots" do
  53. 1 expect(subject).to eq(result)
  54. end
  55. end
  56. 1 context "when there are appointments" do
  57. 1 let(:result) do
  58. {
  59. 1 "05-10-2023" => [
  60. "09:00 AM - 11:00 AM",
  61. "12:00 PM - 01:00 PM",
  62. "02:00 PM - 05:00 PM"
  63. ],
  64. "06-10-2023" => ["09:00 AM - 05:00 PM"]
  65. }
  66. end
  67. 1 before do
  68. 1 Appointment.create!(
  69. doctor_id: doctor.id,
  70. start_time: DateTime.parse("05-10-2023 11:00 AM UTC"),
  71. patient_name: "John Doe"
  72. )
  73. 1 Appointment.create!(
  74. doctor_id: doctor.id,
  75. start_time: DateTime.parse("05-10-2023 01:00 PM UTC"),
  76. patient_name: "John Doe"
  77. )
  78. end
  79. 1 it "returns available slots" do
  80. 1 expect(subject).to eq(result)
  81. end
  82. end
  83. end
  84. end

spec/services/create_appointments_service_spec.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. 1 require "test_helper"
  2. 1 RSpec.describe CreateAppointmentsService do
  3. 6 let(:doctor) { create(:doctor) }
  4. 1 let(:params) { {} }
  5. 1 describe ".call" do
  6. 6 subject { described_class.call(doctor, params) }
  7. 1 before do
  8. 5 subject
  9. end
  10. 1 context "with single appointment" do
  11. 1 let(:params) do
  12. 2 {appointment: {patient_name: "John Doe", start_time: "05-10-2023 09:00 AM"}}
  13. end
  14. 1 it "builds and save appointment" do
  15. 1 expect(doctor.appointments.count).to eq(1)
  16. end
  17. 1 it "returns code and appointment" do
  18. 1 expect(subject).to eq([201, [Appointment::Presenter.new(doctor.appointments.first).to_h]])
  19. end
  20. end
  21. 1 context "with multiple appointments" do
  22. 1 let(:params) do
  23. 1 {appointments: [
  24. {patient_name: "John Doe", start_time: "05-10-2023 09:00 AM"},
  25. {patient_name: "Jane Doe", start_time: "05-10-2023 10:00 AM"}
  26. ]}
  27. end
  28. 1 it "builds and save appointment" do
  29. 1 expect(doctor.appointments.count).to eq(2)
  30. end
  31. end
  32. 1 context "with invalid appointment" do
  33. 1 let(:params) do
  34. 2 {appointment: {patient_name: "John Doe", start_time: "05-10-2023 07:00 AM"}}
  35. end
  36. 1 before do
  37. 2 allow_any_instance_of(Appointment).to receive(:save).and_return(false)
  38. end
  39. 1 it "do not save appointment" do
  40. 1 expect(doctor.appointments.count).to eq(0)
  41. end
  42. 1 it "returns appointment" do
  43. 1 expect(subject).to eq([400, [{errors: {start_time: ["must be within the doctor's working hours"]}}]])
  44. end
  45. end
  46. end
  47. end

spec/test_helper.rb

100.0% lines covered

13 relevant lines. 13 lines covered and 0 lines missed.
    
  1. 1 require_relative "spec_helper"
  2. 1 require_relative "../app"
  3. # Exit if the environment is not set.
  4. 1 exit unless ENV["RACK_ENV"] == "test"
  5. 1 require "database_cleaner/active_record"
  6. 1 RSpec.configure do |config|
  7. 1 config.include FactoryBot::Syntax::Methods
  8. 1 config.before(:suite) do
  9. 1 FactoryBot.find_definitions
  10. 1 DatabaseCleaner.strategy = :transaction
  11. 1 DatabaseCleaner.clean_with(:truncation)
  12. end
  13. 1 config.around(:each) do |example|
  14. 91 DatabaseCleaner.cleaning do
  15. 91 example.run
  16. end
  17. end
  18. end