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%
)
- 1
require "dotenv"
- 1
Dotenv.load
- 1
require "sinatra"
- 1
require "sinatra/activerecord"
- 1
require "sinatra/json"
- 1
require "yaml"
- 1
require "erb"
- 1
require "zeitwerk"
- 1
require "securerandom"
- 1
require "pry" if ENV["RACK_ENV"] != "production"
- 1
loader = Zeitwerk::Loader.new
- 1
loader.push_dir(File.join(__dir__, "app/models"))
- 1
loader.push_dir(File.join(__dir__, "app/controllers"))
- 1
loader.push_dir(File.join(__dir__, "app/services"))
- 1
loader.push_dir(File.join(__dir__, "config"))
- 1
loader.setup
- 1
loader.eager_load
# Load database configurations based on the environment.
# By default, Sinatra's environment is set to development.
# change `ENV['RACK_ENV']` to different ones when needed.
- 1
erb_content = ERB.new(File.read("config/database.yml")).result
- 1
db_config = YAML.safe_load(erb_content)[Sinatra::Base.environment.to_s]
- 1
set :database, db_config
# Start the application if this file is executed directly.
- 1
if __FILE__ == $0
Router.run!
end
- 1
class AppointmentsController
- 1
def self.call(action, params)
- 28
params = ParamsSchema.call(params)
- 28
if params.errors.any?
- 1
{error: params.errors.to_h}
else
- 27
doctor = Doctor.find_by(id: params[:doctor_id])
- 27
return {error: "Doctor not found"} unless doctor
- 27
new(doctor, params.to_h).public_send(action)
end
end
- 1
def initialize(doctor, params)
- 27
self.doctor = doctor
- 27
self.params = params
end
- 1
def index
- 5
AvailableSlotService.call(
doctor,
start_date: params[:start_date],
end_date: params[:end_date]
)
end
- 1
def create
# REFACTOR: Move this check logic to schema validation level
- 11
if params[:appointments].present? || params[:appointment].present?
- 7
CreateAppointmentsService.call(doctor, params)
else
- 4
[400, {error: "No appointments to create"}]
end
end
- 1
def update
# REFACTOR: Move this to similar service as CreateAppointmentsService or combine them in one
- 6
appointment = doctor.appointments.find_by(id: params[:appointment_id])
- 6
if params[:appointment].present? && appointment&.update(params[:appointment])
- 3
[200, Appointment::Presenter.new(appointment).to_h]
else
- 3
[400, update_error(appointment)]
end
end
- 1
def delete
# REFACTOR: Move this to similar service as CreateAppointmentsService or combine them in one
- 4
appointment = doctor.appointments.find_by(id: params[:appointment_id])
- 4
return 204 if appointment&.destroy
- 2
[400, {error: "Appointment not found"}]
end
- 1
private
- 1
attr_accessor :doctor, :params
# REFACTOR: Use null object pattern for appointment and this method can be
# removed
- 1
def update_error(appointment)
- 3
if appointment&.errors&.any?
{error: appointment.errors.messages}
else
- 3
{error: "Appointment not found"}
end
end
end
- 1
require "dry-schema"
- 1
class AppointmentsController
- 1
ParamsSchema = Dry::Schema.Params do
- 1
required(:doctor_id).filled(:integer)
- 1
optional(:start_date).filled(:date)
- 1
optional(:end_date).filled(:date)
- 1
optional(:appointment_id).filled(:integer)
- 1
optional(:appointments).value(:array).each do
- 1
schema do
- 1
required(:patient_name).filled(:string)
- 1
required(:start_time).filled(:string)
end
end
- 1
optional(:appointment).schema do
- 1
required(:patient_name).filled(:string)
- 1
required(:start_time).filled(:string)
end
end
# IndexSchema = Dry::Schema.Params do
# required(:doctor_id).filled(:integer)
# optional(:start_date).filled(:date)
# optional(:end_date).filled(:date)
# end
# CreateSchema = Dry::Schema.Params do
# required(:doctor_id).filled(:integer)
# optional(:appointment_id).filled(:integer)
# optional(:appointments).value(:array).each do
# schema do
# required(:patient_name).filled(:string)
# required(:start_time).filled(:string)
# end
# end
# optional(:appointment).schema do
# required(:patient_name).filled(:string)
# required(:start_time).filled(:string)
# end
# end
end
- 1
class DoctorsController
- 1
def self.call(method, params)
- 4
doctor = Doctor.where(id: params[:doctor_id]).first_or_initialize
- 4
new(doctor).public_send(method)
end
- 1
def initialize(doctor)
- 4
self.doctor = Doctor::Presenter.new(doctor)
end
- 1
delegate :availability, to: :doctor
- 1
private
- 1
attr_accessor :doctor
end
# == Schema Information
#
# Table name: appointments
#
# id :integer not null, primary key
# doctor_id :integer
# patient_name :string
# start_time :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
# models/appointment.rb
- 1
class Appointment < ActiveRecord::Base
- 1
belongs_to :doctor
- 1
scope :overlapping, OverlappingScope
- 1
validates :patient_name, :start_time, presence: true
- 1
validate :start_time_within_working_hours
- 1
validate :no_double_booking
- 1
private
- 1
def start_time_within_working_hours
- 34
unless start_time && doctor&.appointment_within_working_hours?(start_time)
- 2
errors.add(:start_time, "must be within the doctor's working hours")
end
end
- 1
def no_double_booking
- 34
overlapping_appointments = self.class.overlapping(self)
- 34
if overlapping_appointments.exists?
errors.add(:start_time, "has overlapping appointments")
end
end
end
- 1
class Appointment::OverlappingScope
- 1
def self.call(appointment)
- 34
new(
appointment_id: appointment.id,
doctor_id: appointment.doctor_id,
start_time: appointment.start_time,
slot_duration: appointment.doctor.slot_duration,
break_duration: appointment.doctor.break_duration
).call
end
- 1
def initialize(appointment_id: nil, doctor_id: nil, start_time: nil, slot_duration: nil, break_duration: nil)
- 37
self.id = appointment_id
- 37
self.doctor_id = doctor_id
- 37
self.start_time = start_time
- 37
self.slot_duration = slot_duration
- 37
self.break_duration = break_duration
end
- 1
def call
- 38
module_parent
.where(doctor_id: doctor_id)
.where(sql)
.where.not(id: id)
end
- 1
delegate :module_parent, to: :class
- 1
private
- 1
attr_accessor :id, :doctor_id, :start_time, :slot_duration, :break_duration, :options
- 1
def sql
- 38
format(
- 38
<<-SQL,
(start_time <= '%{slot_end_time}' AND datetime(start_time, '+%{slot_duration} minutes') > '%{start_time}') OR
('%{start_time}' < datetime('%{slot_end_time}', '+%{break_duration} minutes') AND '%{start_time}' >= '%{slot_end_time}')
SQL
slot_end_time: slot_end_time.strftime("%Y-%m-%d %H:%M:%S"),
start_time: start_time.strftime("%Y-%m-%d %H:%M:%S"),
slot_duration: slot_duration,
break_duration: break_duration
)
end
- 1
def slot_end_time
- 38
@slot_end_time ||= (start_time + slot_duration.minutes)
end
end
- 1
class Appointment::Presenter < SimpleDelegator
- 1
def formatted_start_time
- 19
start_time.strftime("%d-%m-%Y %H:%M")
end
- 1
def formatted_end_time
- 19
calc_end_time.strftime("%d-%m-%Y %H:%M")
end
- 1
def to_h
{
- 18
id: id,
patient_name: patient_name,
start_time: formatted_start_time,
end_time: formatted_end_time
}
end
- 1
private
- 1
def calc_end_time
- 19
start_time + doctor.slot_duration.minutes
end
end
- 1
require "jwt"
- 1
class AuthToken
- 1
SECRET_KEY = ENV["SECRET_KEY"]
- 1
ALGORITHM = "HS256"
- 1
def self.issue_token(payload)
- 35
JWT.encode(payload, SECRET_KEY, ALGORITHM)
end
- 1
def self.decode_token(token)
- 29
JWT.decode(token, SECRET_KEY, true, {algorithm: ALGORITHM}).first
end
end
# == Schema Information
#
# Table name: doctors
#
# id :integer not null, primary key
# name :string
# work_start_time :time
# work_end_time :time
# slot_duration :integer
# appointment_slot_limit :integer
# break_duration :integer
# created_at :datetime not null
# updated_at :datetime not null
#
- 1
class Doctor < ActiveRecord::Base
- 1
has_many :appointments
# Validates the presence of name, work_start_time, work_end_time, slot_duration, and break_duration.
- 1
validates :name, :work_start_time, :work_end_time, :slot_duration,
:break_duration, presence: true
# Validates that work_start_time is before work_end_time.
- 1
validate :work_start_time_before_work_end_time
# Validates that slot_duration is shorter than the doctor's working hours.
- 1
validate :slot_duration_is_shorter_than_working_hours
# Checks if the appointment time is within the doctor's working hours.
#
# @param appointment_time [Time] the time of the appointment
# @return [Boolean] true if the appointment is within working hours, false otherwise
- 1
def appointment_within_working_hours?(appointment_time)
- 36
appt_time_as_time = Time.new(
work_start_time.year,
work_start_time.month,
work_start_time.day,
appointment_time.hour,
appointment_time.min,
appointment_time.sec,
appointment_time.zone
)
# Calculate appointment end time using the doctor's slot_duration
- 36
appointment_end_time = appt_time_as_time + slot_duration.minutes
# Check if both start and end times are within doctor's working hours.
- 36
appt_time_as_time.between?(work_start_time, work_end_time) &&
appointment_end_time.between?(work_start_time, work_end_time)
end
- 1
private
# Validates that work_start_time is before work_end_time.
- 1
def work_start_time_before_work_end_time
- 49
return unless work_start_time && work_end_time
- 47
if work_start_time >= work_end_time
- 2
errors.add(:work_start_time, "must be before work end time")
end
end
# Validates that slot_duration is shorter than the doctor's working hours.
- 1
def slot_duration_is_shorter_than_working_hours
- 49
return unless work_start_time && work_end_time && slot_duration
- 46
work_duration = (work_end_time - work_start_time) / 60
- 46
if slot_duration >= work_duration
- 3
errors.add(:slot_duration, "must be shorter than working hours")
end
end
end
- 1
require "delegate"
- 1
class Doctor::Presenter < SimpleDelegator
- 1
def availability
{
- 3
doctor_id: id,
working_hours: {
start_time: work_start_time.to_formatted_s(:time),
end_time: work_end_time.to_formatted_s(:time)
}
}
end
end
# == Schema Information
#
# Table name: organizations
#
# id :integer not null, primary key
# name :string
# email :string
# api_key_digest :string
# token :string
# created_at :datetime not null
# updated_at :datetime not null
#
- 1
require "bcrypt"
- 1
class Organization < ActiveRecord::Base
- 1
validates :email, presence: true, uniqueness: true
- 1
validates :name, :api_key_digest, presence: true
- 1
def api_key=(new_api_key)
- 41
self.api_key_digest = BCrypt::Password.create(new_api_key)
end
- 1
def authenticate_api_key(test_api_key)
- 3
BCrypt::Password.new(api_key_digest) == test_api_key
end
end
- 1
class ApiKeyService
- 1
def generate_api_key
- 11
SecureRandom.hex(32)
end
- 1
def rotate_api_key(organization)
- 2
api_key = generate_api_key
- 2
organization.api_key = api_key
- 2
organization.save!
- 2
api_key
end
- 1
private
- 1
attr_accessor :organization
end
- 1
module Authentication
- 1
attr_accessor :errors
- 1
def exchange_key
- 7
organization = Organization.find_by(email: params[:email])
- 7
error = {error: "Incorrect email or api_key"}
- 7
return error unless organization&.authenticate_api_key(params[:api_key])
- 3
{token: AuthToken.issue_token(organization_id: organization.id)}
end
- 1
def current_organization
- 2
@current_organization ||= Organization.find_by(id: token["organization_id"]) if authenticated?
end
- 1
def authenticated?
- 29
self.errors = []
- 29
validate_token(request.env["Authorization"] || request.env["HTTP_AUTHORIZATION"])
- 29
errors.empty?
end
- 1
private
- 1
attr_accessor :token
- 1
def validate_token(token)
- 29
case token
when /^Bearer /
- 28
self.token = AuthToken.decode_token(token.gsub(/^Bearer /, ""))
else
- 1
errors << ["Token must be a Bearer token not: #{token}"] # TODO: add to locale
end
rescue JWT::DecodeError => e
- 1
errors << "Invalid token " + e.message # TODO: add to locale
end
end
- 1
class AvailableSlotService
- 1
def self.call(*args, **kwargs)
- 5
new(*args, **kwargs).call
end
- 1
def initialize(doctor, start_date: nil, end_date: nil)
- 8
self.doctor = doctor
- 8
self.start_date = start_date || Date.current
- 8
self.end_date = find_end_date(end_date, start_date)
- 8
self.result = {}
end
# Returns a hash containing available slots for a given doctor between start_time and end_time
#
# @return [Hash] A hash containing available slots for a given doctor between start_time and end_time
- 1
def call
- 8
return result if result.present?
# 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
- 8
(start_date..end_date).each do |date|
- 31
working_hours = [(convert_to_time(date, doctor.work_start_time)..convert_to_time(date, doctor.work_end_time))]
- 31
appointments = find_appointments_for(date)
- 31
if appointments.any?
- 1
appointments.each do |appointment|
- 2
occupied_range = (appointment.start_time..(appointment.start_time + doctor.slot_duration.minutes + doctor.break_duration.minutes))
- 2
working_hours[-1] = subtract_ranges(working_hours[-1], occupied_range)
- 2
working_hours.flatten!
end
- 4
working_hours.map { |range| format_range(range) }
end
- 64
result[date.strftime("%d-%m-%Y")] = working_hours.map { |range| format_range(range) }
end
- 8
result
end
- 1
private
- 1
attr_accessor :doctor, :start_date, :end_date, :result
- 1
def find_end_date(end_date, start_date)
- 8
return end_date if end_date.present?
- 3
(start_date || Date.current) + 6.days
end
# Converts a given date and time to a Time object
#
# @param date [Date] The date to convert
# @param time [Time] The time to convert
# @return [Time] The converted Time object
- 1
def convert_to_time(date, time)
- 62
Time.new(date.year, date.month, date.day, time.hour, time.min, 0)
end
# Finds appointments for a given date
#
# @param date [Date] The date to find appointments for
# @return [ActiveRecord::Relation] An ActiveRecord relation containing appointments for the given date
- 1
def find_appointments_for(date)
- 31
@doctor.appointments.where("DATE(start_time) = ?", date).order(:start_time)
end
# Subtracts an occupied range from a given range
#
# @param range [Range] The range to subtract from
# @param occupied [Range] The occupied range to subtract
# @return [Array<Range>] An array of ranges representing the available slots
- 1
def subtract_ranges(range, occupied)
- 2
ranges = []
- 2
if range.include?(occupied)
- 2
ranges += divide_range(range, occupied)
end
- 2
ranges
end
# Divides a given range into two ranges separated by a gap
#
# @param range [Range] The range to divide
# @param gap [Range] The gap to divide the range by
# @return [Array<Range>] An array of ranges representing the divided range
- 1
def divide_range(range, gap)
- 2
ranges = []
# Check if there is possibility to add appointment before gap
- 2
if gap.begin - range.begin >= (doctor.slot_duration.minutes + doctor.break_duration.minutes)
- 2
ranges << (range.begin..gap.begin)
end
# Check if there is possibility to add appointment after gap
- 2
if range.end - gap.end >= doctor.slot_duration.minutes
- 2
ranges << (gap.end..range.end)
end
- 2
ranges
end
# Formats a given range to a string
#
# @param range [Range] The range to format
# @return [String] A string representing the formatted range
- 1
def format_range(range)
- 36
"#{format_time(range.begin)} - #{format_time(range.end)}".squeeze(" ")
end
# Formats a given time to a string
#
# @param time [Time] The time to format
# @return [String] A string representing the formatted time
- 1
def format_time(time)
- 72
time.strftime("%I:%M %p").strip
end
end
- 1
class CreateAppointmentsService
- 1
def self.call(*args)
- 11
new(*args).call
end
- 1
def initialize(doctor, params)
- 11
self.doctor = doctor
- 11
self.params = params
end
- 1
def call
- 11
self.result = if params[:appointments].present?
- 4
params[:appointments].map do |appointment_params|
- 8
create_appointment(appointment_params)
end
else
- 7
[create_appointment(params[:appointment])]
end
- 11
result
end
- 1
def result
- 26
if @result&.any? { _1[:errors] }
- 2
[400, @result]
else
- 9
[201, @result]
end
end
- 1
private
- 1
attr_accessor :doctor, :params
- 1
attr_writer :result
- 1
def create_appointment(appointment_params)
- 15
appointment = doctor.appointments.build(appointment_params)
- 15
if appointment.save
- 13
Appointment::Presenter.new(appointment).to_h
else
- 2
{errors: appointment.errors.messages}
end
end
end
# Endpoint to find a doctors working hours
# Endpoint to book a doctors open slot
# Endpoint to update a doctors appointment
# Endpoint to delete a doctors appointment
# Endpoint to view a doctors availability
- 1
class Router < Sinatra::Base
- 1
helpers Authentication
- 1
before do
- 31
content_type :json
- 31
unless request.accept? "application/json"
halt 415, {error: "Server only supports application/json"}.to_json
end
end
- 1
before "/api/*" do
- 27
return if authenticated?
- 1
halt 403, {error: errors.join(" ")}.to_json
end
- 1
get "/api/v1" do
- 1
{message: "pong"}.to_json
end
- 1
post "/exchange_key" do
- 4
exchange_key.to_json
end
- 1
get "/api/v1/doctors/:doctor_id/hours" do
# REFACTOR: This should be hours, not availability
- 2
DoctorsController.call(:availability, params).to_json
end
- 1
get "/api/v1/doctors/:doctor_id/availability" do
# REFACTOR: This should be availability, not index
- 4
AppointmentsController.call(:index, params).to_json
end
# TODO: Add a route to view a doctor's appointments
# get "/api/v1/doctors/:doctor_id/appointments" do
# AppointmentsController.call(:index, params).to_json
# end
- 1
post "/api/v1/doctors/:doctor_id/appointments" do
# TODO: right now this is using form params, but it should be using json params
- 9
code, response = AppointmentsController.call(:create, params)
- 9
status code
- 9
response.to_json
end
- 1
put "/api/v1/doctors/:doctor_id/appointments/:appointment_id" do
- 6
code, response = AppointmentsController.call(:update, params)
- 6
status code
- 6
response.to_json
end
- 1
delete "/api/v1/doctors/:doctor_id/appointments/:appointment_id" do
- 4
code, response = AppointmentsController.call(:delete, params)
- 4
status code
- 4
response&.to_json
end
end
- 1
require "test_helper"
- 1
require "rack/test"
- 1
RSpec.describe Router do
- 1
include Rack::Test::Methods
- 27
let(:organization) { create(:organization) }
- 27
let(:token) { AuthToken.issue_token(organization_id: organization.id) }
- 27
let(:headers) { {"Authorization" => "Bearer #{token}"} }
- 1
def app
- 31
Router
end
- 1
describe "GET /api/" do
- 1
context "when authenticated" do
- 1
it "responds with 200" do
- 1
get "/api/v1", nil, headers
- 1
expect(last_response).to be_ok
end
end
- 1
context "when not authenticated" do
- 2
let(:headers) { {} }
- 1
it "responds with 403" do
- 1
get "/api/v1", nil, headers
- 1
expect(last_response.status).to eq(403)
end
end
end
- 1
describe "POST /exchange_key" do
- 3
let(:organization) { create(:organization, api_key: api_key) }
- 5
let(:api_key) { ApiKeyService.new.generate_api_key }
- 1
before do
- 4
post "/exchange_key", params
end
- 1
context "when valid params" do
- 3
let(:params) { {email: organization.email, api_key: api_key} }
- 1
it "responds with 200" do
- 1
expect(last_response).to be_ok
end
- 1
it "returns a token" do
- 1
expect(JSON.parse(last_response.body)).to include("token")
end
end
- 1
context "when invalid params" do
- 3
let(:params) { {email: "invalid", api_key: api_key} }
- 1
it "responds with 200" do
- 1
expect(last_response).to be_ok
end
- 1
it "returns a token" do
- 1
expect(JSON.parse(last_response.body)).to include("error")
end
end
end
- 1
describe "GET /api/v1/doctors/:doctor_id/hours" do
- 3
let(:doctor) { create(:doctor) }
- 1
before do
- 2
get "/api/v1/doctors/#{doctor.id}/hours", nil, headers
end
- 1
it "responds with 200" do
- 1
expect(last_response).to be_ok
end
- 1
it "returns the doctor's availability" do
- 1
expect(JSON.parse(last_response.body)).to include("working_hours")
end
end
- 1
describe "GET /api/v1/doctors/:doctor_id/availability" do
- 5
let(:doctor) { create(:doctor) }
- 1
before do
- 4
get "/api/v1/doctors/#{doctor.id}/availability", query_params, headers
end
- 1
context "with start and end date" do
- 3
let(:query_params) { {start_date: "2019-01-01", end_date: "2019-01-2"} }
- 1
it "responds with 200" do
- 1
expect(last_response).to be_ok
end
- 1
it "returns the doctor's availability" do
- 1
expect(JSON.parse(last_response.body)).to include("01-01-2019", "02-01-2019")
- 1
expect(JSON.parse(last_response.body)).not_to include("03-01-2019")
end
end
- 1
context "with start date only" do
- 3
let(:query_params) { {start_date: "2019-01-01"} }
- 1
it "responds with 200" do
- 1
expect(last_response).to be_ok
end
- 1
it "returns the doctor's availability" do
- 1
expect(JSON.parse(last_response.body)).to include("01-01-2019", "07-01-2019")
end
end
end
- 1
describe "POST /api/v1/doctors/:doctor_id/appointments" do
- 10
let(:doctor) { create(:doctor) }
- 1
before do
- 9
post "/api/v1/doctors/#{doctor.id}/appointments", params, headers
end
- 1
context "with valid params" do
- 1
context "with multiple appointments" do
- 1
let(:params) do
{
- 3
appointments: [
{
patient_name: "John Doe",
start_time: "2019-01-01 09:00 AM UTC"
},
{
patient_name: "Jane Doe",
start_time: "2019-01-01 10:00 AM UTC"
}
]
}
end
- 1
it "responds with 201" do
- 1
expect(last_response).to be_created
end
- 1
it "creates the appointments" do
- 1
expect(Appointment.count).to eq(2)
end
- 1
it "returns the appointments" do
- 3
expect(JSON.parse(last_response.body).map { _1["patient_name"] }).to include("John Doe", "Jane Doe")
end
end
- 1
context "with a single appointment" do
- 1
let(:params) do
- 3
{appointment: {patient_name: "John Doe", start_time: "2019-01-01 09:00 AM UTC"}}
end
- 1
it "responds with 201" do
- 1
expect(last_response).to be_created
end
- 1
it "creates the appointment" do
- 1
expect(Appointment.count).to eq(1)
end
- 1
it "returns the appointment" do
- 1
expect(JSON.parse(last_response.body).first.values).to include("John Doe")
end
end
end
- 1
context "with invalid params" do
- 1
let(:params) do
- 3
{}
end
- 1
it "responds with 400" do
- 1
expect(last_response.status).to eq(400)
end
- 1
it "returns the error" do
- 1
expect(JSON.parse(last_response.body)).to include("error")
end
- 1
it "does not create the appointment" do
- 1
expect(Appointment.count).to eq(0)
end
end
end
- 1
describe "PUT /api/v1/doctors/:doctor_id/appointments/:appointment_id" do
- 7
let(:doctor) { create(:doctor) }
- 7
let(:appointment) { create(:appointment, doctor: doctor) }
- 1
before do
- 6
put "/api/v1/doctors/#{doctor.id}/appointments/#{appointment.id}", params, headers
end
- 1
context "with valid params" do
- 1
let(:params) do
- 3
{appointment: {patient_name: "John Doe", start_time: "2019-01-01 09:00 AM UTC"}}
end
- 1
it "responds with 200" do
- 1
expect(last_response).to be_ok
end
- 1
it "updates the appointment" do
- 1
expect(appointment.reload.patient_name).to eq("John Doe")
end
- 1
it "returns the appointment" do
- 1
expect(JSON.parse(last_response.body).values).to include("John Doe")
end
end
- 1
context "with invalid params" do
- 1
let(:params) do
- 3
{}
end
- 1
it "responds with 400" do
- 1
expect(last_response.status).to eq(400)
end
- 1
it "returns the errors" do
- 1
expect(JSON.parse(last_response.body)).to include("error" => "Appointment not found")
end
- 1
it "does not update the appointment" do
- 1
expect(appointment.reload.patient_name).not_to eq("John Doe")
end
end
end
- 1
describe "DELETE /api/v1/doctors/:doctor_id/appointments/:appointment_id" do
- 5
let(:doctor) { create(:doctor) }
- 5
let(:appointment) { create(:appointment, doctor: doctor) }
- 3
let(:appointment_id) { appointment.id }
- 1
before do
- 4
delete "/api/v1/doctors/#{doctor.id}/appointments/#{appointment_id}", nil, headers
end
- 1
it "responds with 204" do
- 1
expect(last_response).to be_empty
end
- 1
it "deletes the appointment" do
- 1
expect(Appointment.count).to eq(0)
end
- 1
context "when appointment not found" do
- 3
let(:appointment_id) { appointment.id + 1 }
- 1
it "responds with 400" do
- 1
expect(last_response.status).to eq(400)
end
- 1
it "returns the error" do
- 1
expect(JSON.parse(last_response.body)).to include("error" => "Appointment not found")
end
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe AppointmentsController::ParamsSchema do
- 13
let(:schema) { described_class }
- 1
describe "doctor_id" do
- 1
it "is required" do
- 1
result = schema.call({})
- 1
expect(result.errors.to_h).to include(:doctor_id)
end
- 1
it "must be an integer" do
- 1
result = schema.call(doctor_id: "not an integer")
- 1
expect(result.errors.to_h).to include(:doctor_id)
end
end
- 1
describe "appointment_id" do
- 1
it "is optional" do
- 1
result = schema.call(doctor_id: 1)
- 1
expect(result.errors.to_h).not_to include(:appointment_id)
end
- 1
it "must be an integer" do
- 1
result = schema.call(doctor_id: 1, appointment_id: "not an integer")
- 1
expect(result.errors.to_h).to include(:appointment_id)
end
end
- 1
describe "appointments" do
- 1
it "is optional" do
- 1
result = schema.call(doctor_id: 1)
- 1
expect(result.errors.to_h).not_to include(:appointments)
end
- 1
it "must be an array" do
- 1
result = schema.call(doctor_id: 1, appointments: "not an array")
- 1
expect(result.errors.to_h).to include(:appointments)
end
- 1
it "can contain multiple appointment objects" do
- 1
result = schema.call(
doctor_id: 1,
appointments: [
{patient_name: "John Doe", start_time: "2022-01-01T00:00:00Z"},
{patient_name: "Jane Doe", start_time: "2022-01-02T00:00:00Z"}
]
)
- 1
expect(result.errors.to_h).not_to include(:appointments)
end
- 1
describe "appointment object" do
- 3
let(:appointment) { {patient_name: "John Doe", start_time: "2022-01-01T00:00:00Z"} }
- 1
it "must have a patient_name" do
- 1
result = schema.call(doctor_id: 1, appointments: [appointment.merge(patient_name: nil)])
- 1
expect(result.errors.to_h).to include(:appointments)
end
- 1
it "must have a start_time" do
- 1
result = schema.call(doctor_id: 1, appointments: [appointment.merge(start_time: nil)])
- 1
expect(result.errors.to_h).to include(:appointments)
end
end
end
- 1
describe "appointment" do
- 1
it "is optional" do
- 1
result = schema.call(doctor_id: 1)
- 1
expect(result.errors.to_h).not_to include(:appointment)
end
- 1
describe "appointment object" do
- 3
let(:appointment) { {patient_name: "John Doe", start_time: "2022-01-01T00:00:00Z"} }
- 1
it "must have a patient_name" do
- 1
result = schema.call(doctor_id: 1, appointment: appointment.merge(patient_name: nil))
- 1
expect(result.errors.to_h).to include(:appointment)
end
- 1
it "must have a start_time" do
- 1
result = schema.call(doctor_id: 1, appointment: appointment.merge(start_time: nil))
- 1
expect(result.errors.to_h).to include(:appointment)
end
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe AppointmentsController do
- 1
describe ".call" do
- 1
context "when doctor is not found" do
- 1
it "returns an error message" do
- 1
expect(AppointmentsController.call(:action, {})).to eq({error: {doctor_id: ["is missing"]}})
end
end
- 1
context "when doctor is found" do
- 2
let(:doctor) { instance_double(Doctor) }
- 2
let(:params) { {doctor_id: 1} }
- 1
before do
- 1
allow(Doctor).to receive(:find_by).with(id: params[:doctor_id]).and_return(doctor)
- 1
allow_any_instance_of(AppointmentsController).to receive(:index).and_return({})
end
- 1
it "calls the action method on a new instance of the controller" do
- 1
expect_any_instance_of(AppointmentsController).to receive(:index)
- 1
AppointmentsController.call(:index, params)
end
end
end
- 1
describe "#index" do
- 2
let(:doctor) { instance_double(Doctor) }
- 2
let(:params) { {doctor_id: 1} }
- 1
before do
- 1
allow(Doctor).to receive(:find_by).and_return(doctor)
end
- 1
it "calls AvailableSlotService" do
- 1
expect(AvailableSlotService)
.to receive(:call)
.with(doctor, start_date: nil, end_date: nil)
.and_return({})
- 1
AppointmentsController.call(:index, params)
end
end
- 1
describe "#create" do
- 3
let(:doctor) { instance_double(Doctor) }
- 1
before do
- 2
allow(Doctor).to receive(:find_by).and_return(doctor)
end
- 1
context "when no appointments are passed" do
- 2
let(:params) { {doctor_id: 1} }
- 1
it "returns an error message" do
- 1
expect(AppointmentsController.call(:create, params)).to eq([400, {error: "No appointments to create"}])
end
end
- 1
context "when appointments are passed" do
- 2
let(:params) { {doctor_id: 1, appointments: [{patient_name: "John Doe", start_time: "01.01.2023 10:00"}]} }
- 1
it "calls CreateAppointmentService" do
- 1
expect(CreateAppointmentsService)
.to receive(:call)
.with(doctor, params)
.and_return({})
- 1
AppointmentsController.call(:create, params)
end
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe DoctorsController do
- 3
let(:doctor) { create(:doctor) }
- 1
describe ".call" do
- 3
let(:params) { {doctor_id: doctor.id} }
- 1
context "when method is :availability" do
- 2
let(:method) { :availability }
- 1
it "calls availability on the doctor presenter" do
- 1
expect_any_instance_of(Doctor::Presenter).to receive(:availability)
- 1
described_class.call(method, params)
end
end
- 1
context "when method is not :availability" do
- 2
let(:method) { :invalid_method }
- 1
it "raises a NoMethodError" do
- 2
expect { described_class.call(method, params) }.to raise_error(NoMethodError)
end
end
end
end
- 1
require "faker"
- 1
FactoryBot.define do
- 1
factory :appointment do
- 11
start_time { DateTime.parse("01.01.2023 10:00 AM UTC") }
- 14
patient_name { Faker::Name.name }
- 1
doctor
end
end
- 1
require "faker"
- 1
FactoryBot.define do
- 1
factory :doctor do
- 37
name { Faker::Name.name }
- 37
work_start_time { Time.parse("9:00 AM UTC") }
- 37
work_end_time { Time.parse("5:00 PM UTC") }
- 37
slot_duration { 55 }
- 37
break_duration { 5 }
end
end
- 1
FactoryBot.define do
- 1
factory :organization do
- 39
name { "Empire" }
- 37
sequence(:email) { |n| "email_#{n}@example.com" }
- 34
api_key { SecureRandom.hex(32) }
end
end
- 1
require "test_helper"
- 1
RSpec.describe Appointment::OverlappingScope do
- 3
let(:appointment_id) { 1 }
- 3
let(:doctor_id) { 2 }
- 4
let(:start_time) { DateTime.parse("04.10.2023 10:30 AM") }
- 4
let(:slot_duration) { 55 }
- 4
let(:break_duration) { 5 }
- 1
subject do
- 3
described_class.new(
appointment_id: appointment_id,
doctor_id: doctor_id,
start_time: start_time,
slot_duration: slot_duration,
break_duration: break_duration
)
end
- 1
describe "#call" do
- 1
it "do not raise error" do
- 2
expect { subject.call }.not_to raise_error
end
- 1
it "generates the correct SQL" do
- 1
expect(subject.call.to_sql.squish).to eq(
- 1
<<-SQL.squish
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
SQL
), subject.call.to_sql.squish
end
- 1
context "with database records" do
- 2
let(:appointment_id) { nil }
- 2
let(:doctor_id) { doctor.id }
- 1
let(:doctor) do
- 1
Doctor.create!(
name: "Dr. John Doe",
work_start_time: Time.parse("9:00 AM").to_formatted_s(:db),
work_end_time: Time.parse("5:00 PM").to_formatted_s(:db),
slot_duration: 55,
break_duration: 5
)
end
- 1
before do
- 1
Appointment.create!(
doctor_id: doctor_id,
start_time: DateTime.parse("04.10.2023 10:00 AM").to_formatted_s(:db),
patient_name: "John Doe"
)
end
- 1
it "returns the correct records" do
- 1
expect(subject.call.count).to eq(1)
end
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe Appointment::Presenter do
- 4
subject { described_class.new(appointment) }
- 4
let(:appointment) { create(:appointment, start_time: DateTime.parse(start_time)) }
- 4
let(:start_time) { "12-10-2023 10:00" }
- 3
let(:end_time) { "12-10-2023 10:55" }
- 1
describe "#formatted_start_time" do
- 1
it "returns the formatted start time" do
- 1
expect(subject.formatted_start_time).to eq(start_time)
end
end
- 1
describe "#formatted_end_time" do
- 1
it "returns the formatted end time" do
- 1
expect(subject.formatted_end_time).to eq(end_time)
end
end
- 1
describe "#to_h" do
- 1
it "returns a hash of the appointment" do
- 1
expect(subject.to_h).to eq({
id: appointment.id,
patient_name: appointment.patient_name,
start_time: start_time,
end_time: end_time
})
end
end
end
# == Schema Information
#
# Table name: appointments
#
# id :integer not null, primary key
# doctor_id :integer
# patient_name :string
# start_time :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
- 1
require "test_helper"
- 1
RSpec.describe Appointment do
- 1
let(:doctor) do
Doctor.create(
name: "Dr. John Doe",
work_start_time: Time.parse("9:00 AM"),
work_end_time: Time.parse("5:00 PM"),
slot_duration: 55,
break_duration: 5
)
end
- 1
describe "validations" do
end
end
- 1
require "test_helper"
- 1
RSpec.describe AuthToken do
- 3
let(:payload) { {user_id: 1} }
- 3
let(:token) { described_class.issue_token(payload) }
- 1
describe ".issue_token" do
- 1
it "returns a JWT token" do
- 1
expect(token).to be_a(String)
end
end
- 1
describe ".decode_token" do
- 1
it "decodes a JWT token" do
- 1
decoded_payload = described_class.decode_token(token)
- 1
expect(decoded_payload).to eq(payload.stringify_keys)
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe Doctor::Presenter do
- 2
let(:doctor) { build_stubbed(:doctor) }
- 2
let(:presenter) { described_class.new(doctor) }
- 1
describe "#availability" do
- 2
subject { presenter.availability }
- 1
it "returns the doctor's availability" do
- 1
expect(subject).to include(:working_hours)
end
end
end
# == Schema Information
#
# Table name: doctors
#
# id :integer not null, primary key
# name :string
# work_start_time :time
# work_end_time :time
# slot_duration :integer
# appointment_slot_limit :integer
# break_duration :integer
# created_at :datetime not null
# updated_at :datetime not null
#
- 1
require "test_helper"
- 1
RSpec.describe Doctor do
- 9
let(:start_time) { Time.parse("9:00 AM UTC") }
- 9
let(:end_time) { Time.parse("5:00 PM UTC") }
- 10
let(:slot_duration) { 55 }
- 11
let(:break_duration) { 5 }
- 11
let(:name) { "Dr. John Doe" }
- 1
let(:doctor) do
- 11
Doctor.new(
name: name,
work_start_time: start_time,
work_end_time: end_time,
slot_duration: slot_duration,
break_duration: break_duration
)
end
- 1
describe "validations" do
- 10
subject { doctor }
- 2
it { is_expected.to be_valid }
- 1
context "when name is not present" do
- 2
let(:name) { nil }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when work_start_time is missing" do
- 2
let(:start_time) { nil }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when work_end_time is missing" do
- 2
let(:end_time) { nil }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when slot_duration is missing" do
- 2
let(:slot_duration) { nil }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when break_duration is missing" do
- 2
let(:break_duration) { nil }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when work_start_time is after work_end_time" do
- 2
let(:start_time) { Time.parse("5:00 PM UTC") }
- 2
let(:end_time) { Time.parse("9:00 AM UTC") }
- 1
it "is invalid" do
- 1
expect(subject).to be_invalid
end
end
- 1
context "when work_start_time is equal to work_end_time" do
- 2
let(:start_time) { Time.parse("5:00 PM UTC") }
- 2
let(:end_time) { Time.parse("5:00 PM UTC") }
- 1
it "is invalid" do
- 1
expect(subject).to be_invalid
end
end
- 1
context "when slot_duration is greater than work duration" do
- 2
let(:slot_duration) { 9 * 60 }
- 1
it "is invalid" do
- 1
expect(subject).to be_invalid
end
end
end
- 1
describe "#appointment_within_working_hours?" do
- 1
context "within working hours" do
- 2
let(:appointment_time) { Time.parse("11:00 AM UTC") }
- 1
it "returns true" do
- 1
expect(doctor.appointment_within_working_hours?(appointment_time)).to eq(true)
end
end
- 1
context "outside working hours" do
- 2
let(:appointment_time) { Time.parse("8:00 AM UTC") }
- 1
it "returns false" do
- 1
expect(doctor.appointment_within_working_hours?(appointment_time)).to eq(false)
end
end
end
end
# == Schema Information
#
# Table name: organizations
#
# id :integer not null, primary key
# name :string
# email :string
# api_key_digest :string
# token :string
# created_at :datetime not null
# updated_at :datetime not null
#
- 1
require "test_helper"
- 1
RSpec.describe Organization do
- 1
describe "validations" do
- 2
subject { build(:organization) }
- 2
it { is_expected.to be_valid }
- 1
context "when email is not present" do
- 2
subject { build(:organization, email: nil) }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when email is not unique" do
- 2
let(:email) { "foo@example.com" }
- 2
subject { build(:organization, email: email) }
- 2
before { create(:organization, email: email) }
- 2
it { is_expected.to be_invalid }
end
- 1
context "when name is not present" do
- 2
subject { build(:organization, name: nil) }
- 2
it { is_expected.to be_invalid }
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe ApiKeyService do
- 4
subject { described_class.new }
- 3
let(:organization) { create(:organization) }
- 1
describe "#generate_api_key" do
- 1
it "returns a 32 character string" do
- 1
expect(subject.generate_api_key.length).to eq(64)
end
end
- 1
describe "#rotate_api_key" do
- 1
it "returns a 64 character string" do
- 1
expect(subject.rotate_api_key(organization).length).to eq(64)
end
- 1
it "updates the organization's api_key" do
- 4
expect { subject.rotate_api_key(organization) }.to change { organization.reload.api_key_digest }
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe Authentication do
- 1
let(:described_module) do
- 5
Class.new do
- 5
attr_accessor :params, :request
- 5
def initialize(params: {}, request: {})
- 5
@params = params
- 5
@request = request
end
- 5
include Authentication
end.new(params: params, request: OpenStruct.new(env: headers))
end
- 5
let(:organization) { create(:organization, api_key: api_key) }
- 5
let(:api_key) { ApiKeyService.new.generate_api_key }
- 5
let(:token) { AuthToken.issue_token({organization_id: organization.id}) }
- 6
let(:headers) { {"Authorization" => "Bearer #{token}"} }
- 1
describe "#exchange_key" do
- 4
subject { described_module.exchange_key }
- 1
context "when organization found" do
- 2
let(:params) { {api_key: api_key, email: organization.email} }
- 1
it "returns JWT token" do
- 1
expect(subject).to eq({token: token})
end
end
- 1
context "when api key is invalid" do
- 2
let(:params) { {api_key: "invalid"} }
- 1
it "returns nil" do
- 1
expect(subject).to eq({error: "Incorrect email or api_key"})
end
end
- 1
context "when organization not found" do
- 2
let(:params) { {api_key: api_key, email: "invalid"} }
- 1
it "returns nil" do
- 1
expect(subject).to eq({error: "Incorrect email or api_key"})
end
end
end
- 1
describe "#current_organization" do
- 3
subject { described_module.current_organization }
- 3
let(:params) { {} }
- 1
context "when token is valid" do
- 1
it "returns organization" do
- 1
expect(subject).to eq(organization)
end
end
- 1
context "when token is invalid" do
- 2
let(:token) { "invalid" }
- 1
it "returns nil" do
- 1
expect(subject).to be_nil
end
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe AvailableSlotService do
- 5
let(:start_date) { Date.parse("05-10-2023") }
- 4
let(:end_date) { Date.parse("06-10-2023") }
- 1
let(:doctor) do
- 4
Doctor.create!(
name: "Dr. John Doe",
work_start_time: Time.parse("9:00 AM UTC"),
work_end_time: Time.parse("5:00 PM UTC"),
slot_duration: 55,
break_duration: 5
)
end
- 1
let(:service) do
- 4
described_class.new(doctor, start_date: start_date, end_date: end_date)
end
- 1
describe ".call" do
- 1
before do
- 1
allow(AvailableSlotService).to receive(:new).and_return(service)
end
- 1
it "calls new" do
- 1
described_class.call(doctor)
- 1
expect(AvailableSlotService).to have_received(:new)
end
end
- 1
describe "#call" do
- 4
subject { service.call }
- 1
context "with missing end_date" do
- 2
let(:end_date) { nil }
- 1
let(:result) do
{
- 1
"05-10-2023" => ["09:00 AM - 05:00 PM"],
"06-10-2023" => ["09:00 AM - 05:00 PM"],
"07-10-2023" => ["09:00 AM - 05:00 PM"],
"08-10-2023" => ["09:00 AM - 05:00 PM"],
"09-10-2023" => ["09:00 AM - 05:00 PM"],
"10-10-2023" => ["09:00 AM - 05:00 PM"],
"11-10-2023" => ["09:00 AM - 05:00 PM"]
}
end
- 1
it "returns all slots for the next 7 days" do
- 1
expect(subject).to eq(result)
end
end
- 1
context "when there are no appointments" do
- 1
let(:result) do
{
- 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.
"06-10-2023" => ["09:00 AM - 05:00 PM"]
}
end
- 1
it "returns all slots" do
- 1
expect(subject).to eq(result)
end
end
- 1
context "when there are appointments" do
- 1
let(:result) do
{
- 1
"05-10-2023" => [
"09:00 AM - 11:00 AM",
"12:00 PM - 01:00 PM",
"02:00 PM - 05:00 PM"
],
"06-10-2023" => ["09:00 AM - 05:00 PM"]
}
end
- 1
before do
- 1
Appointment.create!(
doctor_id: doctor.id,
start_time: DateTime.parse("05-10-2023 11:00 AM UTC"),
patient_name: "John Doe"
)
- 1
Appointment.create!(
doctor_id: doctor.id,
start_time: DateTime.parse("05-10-2023 01:00 PM UTC"),
patient_name: "John Doe"
)
end
- 1
it "returns available slots" do
- 1
expect(subject).to eq(result)
end
end
end
end
- 1
require "test_helper"
- 1
RSpec.describe CreateAppointmentsService do
- 6
let(:doctor) { create(:doctor) }
- 1
let(:params) { {} }
- 1
describe ".call" do
- 6
subject { described_class.call(doctor, params) }
- 1
before do
- 5
subject
end
- 1
context "with single appointment" do
- 1
let(:params) do
- 2
{appointment: {patient_name: "John Doe", start_time: "05-10-2023 09:00 AM"}}
end
- 1
it "builds and save appointment" do
- 1
expect(doctor.appointments.count).to eq(1)
end
- 1
it "returns code and appointment" do
- 1
expect(subject).to eq([201, [Appointment::Presenter.new(doctor.appointments.first).to_h]])
end
end
- 1
context "with multiple appointments" do
- 1
let(:params) do
- 1
{appointments: [
{patient_name: "John Doe", start_time: "05-10-2023 09:00 AM"},
{patient_name: "Jane Doe", start_time: "05-10-2023 10:00 AM"}
]}
end
- 1
it "builds and save appointment" do
- 1
expect(doctor.appointments.count).to eq(2)
end
end
- 1
context "with invalid appointment" do
- 1
let(:params) do
- 2
{appointment: {patient_name: "John Doe", start_time: "05-10-2023 07:00 AM"}}
end
- 1
before do
- 2
allow_any_instance_of(Appointment).to receive(:save).and_return(false)
end
- 1
it "do not save appointment" do
- 1
expect(doctor.appointments.count).to eq(0)
end
- 1
it "returns appointment" do
- 1
expect(subject).to eq([400, [{errors: {start_time: ["must be within the doctor's working hours"]}}]])
end
end
end
end
- 1
require_relative "spec_helper"
- 1
require_relative "../app"
# Exit if the environment is not set.
- 1
exit unless ENV["RACK_ENV"] == "test"
- 1
require "database_cleaner/active_record"
- 1
RSpec.configure do |config|
- 1
config.include FactoryBot::Syntax::Methods
- 1
config.before(:suite) do
- 1
FactoryBot.find_definitions
- 1
DatabaseCleaner.strategy = :transaction
- 1
DatabaseCleaner.clean_with(:truncation)
end
- 1
config.around(:each) do |example|
- 91
DatabaseCleaner.cleaning do
- 91
example.run
end
end
end