diff --git a/.tool-versions b/.tool-versions index 310dace..6cffee8 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -ruby 3.2.2 +ruby 3.3.8 nodejs 20.7.0 diff --git a/Gemfile b/Gemfile index 7da8a4c..8af9945 100644 --- a/Gemfile +++ b/Gemfile @@ -14,8 +14,8 @@ gem "administrate", "0.20.1" # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", "1.18.3", require: false -# Rack middleware for blocking & throttling -gem 'rack-attack' +# Rack middleware for blocking & throttling +gem "rack-attack" # Use Sass to process CSS # gem "sassc-rails" @@ -109,6 +109,8 @@ group :development, :test do end group :development do + gem "brakeman", "6.1.2" + gem "bullet" # To ensure code consistency [https://docs.rubocop.org] gem "rubocop", "1.56.2" gem "rubocop-factory_bot", "!= 2.26.0", require: false @@ -118,8 +120,6 @@ group :development do gem "rubocop-rspec_rails", "!= 2.29.0", require: false # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console", "4.2.1" - gem "bullet" - gem "brakeman", "6.1.2" # Preview mail in the browser instead of sending. gem "letter_opener", "1.10.0" diff --git a/app/controllers/api/v1/event_procedures_controller.rb b/app/controllers/api/v1/event_procedures_controller.rb index a88806e..644cbc8 100644 --- a/app/controllers/api/v1/event_procedures_controller.rb +++ b/app/controllers/api/v1/event_procedures_controller.rb @@ -6,28 +6,28 @@ class EventProceduresController < ApiController after_action :verify_authorized, except: :index after_action :verify_policy_scoped, only: :index - def index - authorized_scope = policy_scope(EventProcedure) - - listed_event_procedures = EventProcedures::List.result( - scope: authorized_scope, - params: event_procedure_permitted_query_params - ) - - event_procedures = listed_event_procedures.event_procedures - event_procedures_unpaginated = listed_event_procedures.event_procedures_unpaginated - - total_amount_cents = EventProcedures::TotalAmountCents.call( - event_procedures: event_procedures_unpaginated - ) - - render json: { - total: total_amount_cents.total, - total_paid: total_amount_cents.paid, - total_unpaid: total_amount_cents.unpaid, - event_procedures: serialized_event_procedures(event_procedures) - }, status: :ok - end + def index + authorized_scope = policy_scope(EventProcedure) + + listed_event_procedures = EventProcedures::List.result( + scope: authorized_scope, + params: event_procedure_permitted_query_params + ) + + event_procedures = listed_event_procedures.event_procedures + event_procedures_unpaginated = listed_event_procedures.event_procedures_unpaginated + + total_amount_cents = EventProcedures::TotalAmountCents.call( + event_procedures: event_procedures_unpaginated + ) + + render json: { + total: total_amount_cents.total, + total_paid: total_amount_cents.paid, + total_unpaid: total_amount_cents.unpaid, + event_procedures: serialized_event_procedures(event_procedures) + }, status: :ok + end def create authorize(EventProcedure) diff --git a/app/controllers/api/v1/medical_shift_recurrences_controller.rb b/app/controllers/api/v1/medical_shift_recurrences_controller.rb new file mode 100644 index 0000000..89b332b --- /dev/null +++ b/app/controllers/api/v1/medical_shift_recurrences_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Api + module V1 + class MedicalShiftRecurrencesController < ApiController + before_action :set_recurrence, only: %i[destroy] + after_action :verify_authorized, except: :index + after_action :verify_policy_scoped, only: :index + + def index + authorized_scope = policy_scope(MedicalShiftRecurrence) + recurrences = authorized_scope + .where(deleted_at: nil) + .order(created_at: :desc) + + render json: recurrences, each_serializer: MedicalShiftRecurrenceSerializer + end + + def create + authorize(MedicalShiftRecurrence) + result = MedicalShiftRecurrences::Create.result( + attributes: recurrence_params, + user_id: current_user.id + ) + + if result.success? + render json: { + medical_shift_recurrence: result.medical_shift_recurrence, + shifts_generated: result.shifts_created.count + }, status: :created + else + render json: { errors: result.error }, status: :unprocessable_content + end + end + + def destroy + authorize(@recurrence) + result = MedicalShiftRecurrences::Cancel.call( + medical_shift_recurrence: @recurrence + ) + + if result.success? + render json: { + message: "Recurrence cancelled successfully", + shifts_cancelled: result.shifts_cancelled + }, status: :ok + else + render json: { errors: result.error }, status: :unprocessable_entity + end + end + + private + + def set_recurrence + @recurrence = current_user.medical_shift_recurrences + .where(deleted_at: nil) + .find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Recurrence not found" }, status: :not_found + end + + def recurrence_params + params.require(:medical_shift_recurrence).permit( + :frequency, + :day_of_week, + :day_of_month, + :start_date, + :end_date, + :workload, + :start_hour, + :hospital_name, + :amount_cents + ).to_h + end + end + end +end diff --git a/app/models/medical_shift.rb b/app/models/medical_shift.rb index 86b4fc5..9752e7e 100644 --- a/app/models/medical_shift.rb +++ b/app/models/medical_shift.rb @@ -8,6 +8,7 @@ class MedicalShift < ApplicationRecord monetize :amount belongs_to :user + belongs_to :medical_shift_recurrence, optional: true scope :by_hospital, MedicalShifts::ByHospitalQuery scope :by_month, MedicalShifts::ByMonthQuery @@ -33,4 +34,8 @@ def shift def title "#{hospital_name} | #{workload_humanize} | #{shift}" end + + def recurring? + medical_shift_recurrence_id.present? + end end diff --git a/app/models/medical_shift_recurrence.rb b/app/models/medical_shift_recurrence.rb new file mode 100644 index 0000000..8316b2c --- /dev/null +++ b/app/models/medical_shift_recurrence.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class MedicalShiftRecurrence < ApplicationRecord + acts_as_paranoid + + FREQUENCIES = %w[weekly biweekly monthly_fixed_day].freeze + + has_enumeration_for :workload, with: MedicalShifts::Workloads, create_helpers: true + + monetize :amount + + belongs_to :user + + has_many :medical_shifts, dependent: :nullify + + validates :frequency, presence: true, inclusion: { in: FREQUENCIES } + validates :start_date, presence: true + validates :workload, presence: true + validates :start_hour, presence: true + validates :hospital_name, presence: true + validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 } + + validates :day_of_week, presence: true, if: lambda { + frequency.in?(%w[weekly biweekly]) + }, numericality: { only_integer: true, in: 0..6 } + validates :day_of_month, presence: true, if: lambda { + frequency == "monthly_fixed_day" + }, numericality: { only_integer: true, in: 1..31 } + + validate :day_of_month_blank_for_weekly + validate :day_of_week_blank_for_monthly + validate :end_date_after_start_date + validate :start_date_not_in_past + + scope :active, -> { where(deleted_at: nil) } + scope :needs_generation, MedicalShiftRecurrences::NeedsGenerationQuery + + private + + def day_of_month_blank_for_weekly + return unless frequency.in?(%w[weekly biweekly]) && day_of_month.present? + + errors.add(:day_of_month, "It must be empty for weekly/biweekly recurrence.") + end + + def day_of_week_blank_for_monthly + return unless frequency == "monthly_fixed_day" && day_of_week.present? + + errors.add(:day_of_week, "It must be empty for monthly_fixed_day recurrence.") + end + + def end_date_after_start_date + return unless end_date.present? && start_date.present? && end_date < start_date + + errors.add(:end_date, "End date must be after start date.") + end + + def start_date_not_in_past + return unless start_date.present? && start_date < Time.zone.today + + errors.add(:start_date, "Start date cannot be in the past.") + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 124e143..8568321 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,6 +12,7 @@ class User < ApplicationRecord has_many :event_procedures, dependent: :destroy has_many :medical_shifts, dependent: :destroy + has_many :medical_shift_recurrences, dependent: :destroy has_many :patients, dependent: :destroy has_many :procedures, dependent: :destroy has_many :health_insurances, dependent: :destroy diff --git a/app/operations/medical_shift_recurrences/cancel.rb b/app/operations/medical_shift_recurrences/cancel.rb new file mode 100644 index 0000000..e25dcd0 --- /dev/null +++ b/app/operations/medical_shift_recurrences/cancel.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module MedicalShiftRecurrences + class Cancel < Actor + input :medical_shift_recurrence, type: MedicalShiftRecurrence + + output :shifts_cancelled, type: Integer, default: 0 + + def call + fail!(error: "Recurrence already cancelled") if medical_shift_recurrence.deleted_at.present? + + ActiveRecord::Base.transaction do + cancel_future_shifts + medical_shift_recurrence.destroy + end + end + + private + + def cancel_future_shifts + future_shifts = medical_shift_recurrence + .medical_shifts + .where(deleted_at: nil) + .where("start_date >= ?", Date.current) + + self.shifts_cancelled = future_shifts.count + future_shifts.destroy_all + end + end +end diff --git a/app/operations/medical_shift_recurrences/create.rb b/app/operations/medical_shift_recurrences/create.rb new file mode 100644 index 0000000..bb1a614 --- /dev/null +++ b/app/operations/medical_shift_recurrences/create.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module MedicalShiftRecurrences + class Create < Actor + input :attributes, type: Hash + input :user_id, type: Integer + + output :medical_shift_recurrence, type: MedicalShiftRecurrence + output :shifts_created, type: Array, default: -> { [] } + + GENERATION_HORIZON_MONTHS = 2 + + def call + create_recurrence + initialize_shifts_array + generate_shifts + end + + private + + def create_recurrence + self.medical_shift_recurrence = MedicalShiftRecurrence.new( + attributes.reverse_merge(user_id: user_id) + ) + + fail!(error: medical_shift_recurrence.errors.full_messages) unless medical_shift_recurrence.save + end + + def initialize_shifts_array + self.shifts_created = [] + end + + def generate_shifts + target_date = GENERATION_HORIZON_MONTHS.months.from_now.to_date + dates = MedicalShiftRecurrences::RecurrenceDateCalculatorService.new( + medical_shift_recurrence + ).dates_until(target_date) + + dates.each do |date| + result = MedicalShifts::Create.result( + attributes: shift_attributes(date), + user_id: user_id + ) + + shifts_created << result.medical_shift if result.success? + end + + medical_shift_recurrence.update!(last_generated_until: target_date) if shifts_created.any? + end + + def shift_attributes(date) + { + start_date: date, + start_hour: medical_shift_recurrence.start_hour, + workload: medical_shift_recurrence.workload, + hospital_name: medical_shift_recurrence.hospital_name, + amount_cents: medical_shift_recurrence.amount_cents, + medical_shift_recurrence_id: medical_shift_recurrence.id, + paid: false + } + end + end +end diff --git a/app/operations/medical_shift_recurrences/generate_pending.rb b/app/operations/medical_shift_recurrences/generate_pending.rb new file mode 100644 index 0000000..5c92609 --- /dev/null +++ b/app/operations/medical_shift_recurrences/generate_pending.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module MedicalShiftRecurrences + class GeneratePending < Actor + input :target_date, type: Date, default: -> { 2.months.from_now.to_date } + + output :processed, type: Integer, default: 0 + output :shifts_created, type: Integer, default: 0 + output :errors, type: Array, default: -> { [] } + + def call + self.processed = 0 + self.shifts_created = 0 + self.errors = [] + + MedicalShiftRecurrence.needs_generation(target_date:).find_each do |recurrence| + process_recurrence(recurrence) + end + end + + private + + def process_recurrence(recurrence) + dates = MedicalShiftRecurrences::RecurrenceDateCalculatorService.new(recurrence).dates_until(target_date) + + created_count = 0 + dates.each do |date| + result = MedicalShifts::Create.call( + attributes: shift_attributes(recurrence, date), + user_id: recurrence.user_id + ) + created_count += 1 if result.success? + end + + recurrence.update!(last_generated_until: target_date) if created_count.positive? + + self.processed += 1 + self.shifts_created += created_count + rescue StandardError => e + errors << { recurrence_id: recurrence.id, error: e.message } + end + + def shift_attributes(recurrence, date) + { + start_date: date, + start_hour: recurrence.start_hour, + workload: recurrence.workload, + hospital_name: recurrence.hospital_name, + amount_cents: recurrence.amount_cents, + medical_shift_recurrence_id: recurrence.id, + paid: false + } + end + end +end diff --git a/app/policies/medical_shift_recurrence_policy.rb b/app/policies/medical_shift_recurrence_policy.rb new file mode 100644 index 0000000..8405b56 --- /dev/null +++ b/app/policies/medical_shift_recurrence_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class MedicalShiftRecurrencePolicy < ApplicationPolicy + class Scope < CurrentUserScope + end + + def index? + user.present? + end + + def create? + user.present? + end + + def destroy? + user_owner? + end +end diff --git a/app/queries/medical_shift_recurrences/needs_generation_query.rb b/app/queries/medical_shift_recurrences/needs_generation_query.rb new file mode 100644 index 0000000..fce22be --- /dev/null +++ b/app/queries/medical_shift_recurrences/needs_generation_query.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module MedicalShiftRecurrences + class NeedsGenerationQuery < ApplicationQuery + attr_reader :target_date, :relation + + def initialize(target_date:, relation: MedicalShiftRecurrence) + @target_date = target_date + @relation = relation + end + + def call + relation + .active + .where( + "last_generated_until IS NULL OR last_generated_until < ?", + target_date + ) + end + end +end diff --git a/app/serializers/medical_shift_recurrence_serializer.rb b/app/serializers/medical_shift_recurrence_serializer.rb new file mode 100644 index 0000000..4d109f4 --- /dev/null +++ b/app/serializers/medical_shift_recurrence_serializer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class MedicalShiftRecurrenceSerializer < ActiveModel::Serializer + attributes :id, + :frequency, + :day_of_week, + :day_of_month, + :start_date, + :end_date, + :workload, + :start_hour, + :hospital_name, + :amount_cents, + :last_generated_until, + :user_id + + def amount_cents + object.amount.format + end + + def end_date + object.end_date.strftime("%d/%m/%Y") if object.end_date.present? + end + + def last_generated_until + object.last_generated_until.strftime("%d/%m/%Y") if object.last_generated_until.present? + end + + def start_date + object.start_date.strftime("%d/%m/%Y") + end + + def start_hour + object.start_hour.strftime("%H:%M") + end + + def workload + object.workload_humanize + end +end diff --git a/app/serializers/medical_shift_recurrence_with_shifts_serializer.rb b/app/serializers/medical_shift_recurrence_with_shifts_serializer.rb new file mode 100644 index 0000000..ae897bd --- /dev/null +++ b/app/serializers/medical_shift_recurrence_with_shifts_serializer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class MedicalShiftRecurrenceWithShiftsSerializer < ActiveModel::Serializer + attributes :id, + :frequency, + :day_of_week, + :day_of_month, + :start_date, + :end_date, + :workload, + :start_hour, + :hospital_name, + :amount_cents, + :last_generated_until, + :shifts_generated_count, + :user_id + + def shifts_generated_count + @instance_options[:shifts_count] || 0 + end + + def amount_cents + object.amount.format + end + + def end_date + object.end_date.strftime("%d/%m/%Y") if object.end_date.present? + end + + def last_generated_until + object.last_generated_until.strftime("%d/%m/%Y") if object.last_generated_until.present? + end + + def start_date + object.start_date.strftime("%d/%m/%Y") + end + + def start_hour + object.start_hour.strftime("%H:%M") + end + + def workload + object.workload_humanize + end +end diff --git a/app/serializers/medical_shift_serializer.rb b/app/serializers/medical_shift_serializer.rb index e87338a..eeb8263 100644 --- a/app/serializers/medical_shift_serializer.rb +++ b/app/serializers/medical_shift_serializer.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class MedicalShiftSerializer < ActiveModel::Serializer - attributes :id, :hospital_name, :workload, :date, :hour, :amount_cents, :paid, :shift, :title + attributes :id, :hospital_name, :workload, :date, :hour, :amount_cents, :paid, :shift, :title, + :medical_shift_recurrence_id def date object.start_date.strftime("%d/%m/%Y") @@ -18,4 +19,8 @@ def amount_cents def workload object.workload_humanize end + + def medical_shift_recurrence_id + object.medical_shift_recurrence_id.presence + end end diff --git a/app/services/medical_shift_recurrences/recurrence_date_calculator_service.rb b/app/services/medical_shift_recurrences/recurrence_date_calculator_service.rb new file mode 100644 index 0000000..a07e2a5 --- /dev/null +++ b/app/services/medical_shift_recurrences/recurrence_date_calculator_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module MedicalShiftRecurrences + class RecurrenceDateCalculatorService + def initialize(recurrence) + @recurrence = recurrence + end + + def dates_until(target_date) + return [] if @recurrence.deleted_at.present? + + dates = [] + current_date = starting_point + end_date = effective_end_date(target_date) + + while current_date && current_date <= end_date + dates << current_date + current_date = next_occurrence(current_date) + + break if dates.size > 365 # Safety + end + + dates + end + + private + + def starting_point + if @recurrence.last_generated_until.present? + next_occurrence(@recurrence.last_generated_until) + else + first_occurrence_after_start_date + end + end + + def first_occurrence_after_start_date + case @recurrence.frequency + when "weekly" + find_next_weekly_after(@recurrence.start_date, @recurrence.day_of_week) + when "biweekly" + find_next_biweekly_after(@recurrence.start_date, @recurrence.day_of_week) + when "monthly_fixed_day" + find_next_month_day_after(@recurrence.start_date, @recurrence.day_of_month) + end + end + + def find_next_weekly_after(from_date, target_wday) + # Se from_date já é o dia correto, pula 7 dias + return from_date + 7.days if from_date.wday == target_wday + + # Caso contrário, encontra o próximo dia da semana + days_ahead = target_wday - from_date.wday + days_ahead += 7 if days_ahead.negative? + + from_date + days_ahead.days + end + + def find_next_biweekly_after(from_date, target_wday) + # Se from_date já é o dia correto, pula 14 dias (2 semanas) + return from_date + 14.days if from_date.wday == target_wday + + # Caso contrário, encontra o próximo dia da semana + days_ahead = target_wday - from_date.wday + days_ahead += 7 if days_ahead.negative? + + from_date + days_ahead.days + end + + def find_next_month_day_after(from_date, target_day) + # Se é o dia correto no mês atual, pula para o próximo mês + if from_date.day == target_day && month_has_day?(from_date, target_day) + return next_month_with_day(from_date, target_day) + end + + # Tenta no mês atual primeiro + return from_date.change(day: target_day) if from_date.day < target_day && month_has_day?(from_date, target_day) + + # Se não, próximo mês + next_month_with_day(from_date, target_day) + end + + def next_occurrence(from_date) + case @recurrence.frequency + when "weekly" + from_date + 7.days + when "biweekly" + from_date + 14.days + when "monthly_fixed_day" + next_month_with_day(from_date, @recurrence.day_of_month) + end + end + + def next_month_with_day(from_date, target_day) + current = from_date.next_month.beginning_of_month + + 12.times do + return current.change(day: target_day) if month_has_day?(current, target_day) + + current = current.next_month + end + + nil + end + + def month_has_day?(date, day) + day <= date.end_of_month.day + end + + def effective_end_date(target_date) + dates = [target_date] + dates << @recurrence.end_date if @recurrence.end_date.present? + dates.min + end + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index e6aa216..52a05f8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -66,10 +66,10 @@ # The Bullet gem helps detect N+1 queries and other inefficiencies in ActiveRecord queries. config.after_initialize do - Bullet.enable = true - Bullet.alert = true + Bullet.enable = true + Bullet.alert = true Bullet.bullet_logger = true - Bullet.console = true + Bullet.console = true end # Raises error for missing translations. diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 0aeb6e1..62227ff 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -1,81 +1,79 @@ # frozen_string_literal: true -class Rack::Attack +module Rack + class Attack + ### Configure Cache ### - ### Configure Cache ### + # If you don't want to use Rails.cache (Rack::Attack's default), then + # configure it here. + # + # Note: The store is only used for throttling (not blocklisting and + # safelisting). It must implement .increment and .write like + # ActiveSupport::Cache::Store - # If you don't want to use Rails.cache (Rack::Attack's default), then - # configure it here. - # - # Note: The store is only used for throttling (not blocklisting and - # safelisting). It must implement .increment and .write like - # ActiveSupport::Cache::Store + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + ### Throttle Spammy Clients ### - ### Throttle Spammy Clients ### + # If any single client IP is making tons of requests, then they're + # probably malicious or a poorly-configured scraper. Either way, they + # don't deserve to hog all of the app server's CPU. Cut them off! + # + # Note: If you're serving assets through rack, those requests may be + # counted by rack-attack and this throttle may be activated too + # quickly. If so, enable the condition to exclude them from tracking. - # If any single client IP is making tons of requests, then they're - # probably malicious or a poorly-configured scraper. Either way, they - # don't deserve to hog all of the app server's CPU. Cut them off! - # - # Note: If you're serving assets through rack, those requests may be - # counted by rack-attack and this throttle may be activated too - # quickly. If so, enable the condition to exclude them from tracking. - - # Throttle all requests by IP (60rpm) - # - # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}" - throttle('req/ip', limit: 300, period: 5.minutes) do |req| - req.ip # unless req.path.start_with?('/assets') - end + # Throttle all requests by IP (60rpm) + # + # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}" + throttle("req/ip", limit: 300, period: 5.minutes) do |req| + req.ip # unless req.path.start_with?('/assets') + end - ### Prevent Brute-Force Login Attacks ### + ### Prevent Brute-Force Login Attacks ### - # The most common brute-force login attack is a brute-force password - # attack where an attacker simply tries a large number of emails and - # passwords to see if any credentials match. - # - # Another common method of attack is to use a swarm of computers with - # different IPs to try brute-forcing a password for a specific account. + # The most common brute-force login attack is a brute-force password + # attack where an attacker simply tries a large number of emails and + # passwords to see if any credentials match. + # + # Another common method of attack is to use a swarm of computers with + # different IPs to try brute-forcing a password for a specific account. - # Throttle POST requests to /login by IP address - # - # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}" - throttle('logins/ip', limit: 5, period: 20.seconds) do |req| - if req.path == '/login' && req.post? - req.ip + # Throttle POST requests to /login by IP address + # + # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}" + throttle("logins/ip", limit: 5, period: 20.seconds) do |req| + req.ip if req.path == "/login" && req.post? end - end - # Throttle POST requests to /login by email param - # - # Key: "rack::attack:#{Time.now.to_i/:period}:logins/email:#{normalized_email}" - # - # Note: This creates a problem where a malicious user could intentionally - # throttle logins for another user and force their login requests to be - # denied, but that's not very common and shouldn't happen to you. (Knock - # on wood!) - throttle('logins/email', limit: 5, period: 20.seconds) do |req| - if req.path == '/login' && req.post? - # Normalize the email, using the same logic as your authentication process, to - # protect against rate limit bypasses. Return the normalized email if present, nil otherwise. - req.params['email'].to_s.downcase.gsub(/\s+/, "").presence + # Throttle POST requests to /login by email param + # + # Key: "rack::attack:#{Time.now.to_i/:period}:logins/email:#{normalized_email}" + # + # Note: This creates a problem where a malicious user could intentionally + # throttle logins for another user and force their login requests to be + # denied, but that's not very common and shouldn't happen to you. (Knock + # on wood!) + throttle("logins/email", limit: 5, period: 20.seconds) do |req| + if req.path == "/login" && req.post? + # Normalize the email, using the same logic as your authentication process, to + # protect against rate limit bypasses. Return the normalized email if present, nil otherwise. + req.params["email"].to_s.downcase.gsub(/\s+/, "").presence + end end - end - ### Custom Throttle Response ### + ### Custom Throttle Response ### - # By default, Rack::Attack returns an HTTP 429 for throttled responses, - # which is just fine. - # - # If you want to return 503 so that the attacker might be fooled into - # believing that they've successfully broken your app (or you just want to - # customize the response), then uncomment these lines. - # self.throttled_responder = lambda do |env| - # [ 503, # status - # {}, # headers - # ['']] # body - # end + # By default, Rack::Attack returns an HTTP 429 for throttled responses, + # which is just fine. + # + # If you want to return 503 so that the attacker might be fooled into + # believing that they've successfully broken your app (or you just want to + # customize the response), then uncomment these lines. + # self.throttled_responder = lambda do |env| + # [ 503, # status + # {}, # headers + # ['']] # body + # end + end end - diff --git a/config/routes.rb b/config/routes.rb index 18706ae..441f465 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,7 @@ get "hospital_name_suggestion", to: "medical_shifts#hospital_name_suggestion_index", on: :collection get "amount_suggestions", to: "medical_shifts#amount_suggestions_index", on: :collection end + resources :medical_shift_recurrences, only: %i[index create destroy] resources :patients, only: %i[index create update destroy] resources :procedures, only: %i[index create update destroy] resources :users, only: [:index] do diff --git a/db/migrate/20251120194639_create_medical_shift_recurrences.rb b/db/migrate/20251120194639_create_medical_shift_recurrences.rb new file mode 100644 index 0000000..d2d73a8 --- /dev/null +++ b/db/migrate/20251120194639_create_medical_shift_recurrences.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class CreateMedicalShiftRecurrences < ActiveRecord::Migration[7.1] + def change # rubocop:disable Metrics/MethodLength + create_table :medical_shift_recurrences do |t| + t.references :user, null: false, foreign_key: true, index: true + + t.string :frequency, null: false + t.integer :day_of_week + t.integer :day_of_month + t.date :start_date, null: false + t.date :end_date + + t.string :workload, null: false + t.time :start_hour, null: false + t.string :hospital_name, null: false, default: "" + t.integer :amount_cents, default: 0, null: false + + t.date :last_generated_until + t.datetime :deleted_at + + t.timestamps + end + + add_index :medical_shift_recurrences, :deleted_at + add_index :medical_shift_recurrences, :last_generated_until + add_index :medical_shift_recurrences, %i[user_id deleted_at] + end +end diff --git a/db/migrate/20251120195423_add_recurrence_to_medical_shifts.rb b/db/migrate/20251120195423_add_recurrence_to_medical_shifts.rb new file mode 100644 index 0000000..d6cdc15 --- /dev/null +++ b/db/migrate/20251120195423_add_recurrence_to_medical_shifts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddRecurrenceToMedicalShifts < ActiveRecord::Migration[7.1] + def change + add_reference :medical_shifts, :medical_shift_recurrence, foreign_key: true, null: true, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7dde119..5807e43 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_05_28_125806) do +ActiveRecord::Schema[7.1].define(version: 2025_11_20_195423) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -98,6 +98,27 @@ t.index ["name"], name: "index_hospitals_on_name", unique: true end + create_table "medical_shift_recurrences", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "frequency", null: false + t.integer "day_of_week" + t.integer "day_of_month" + t.date "start_date", null: false + t.date "end_date" + t.string "workload", null: false + t.time "start_hour", null: false + t.string "hospital_name", default: "", null: false + t.integer "amount_cents", default: 0, null: false + t.date "last_generated_until" + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["deleted_at"], name: "index_medical_shift_recurrences_on_deleted_at" + t.index ["last_generated_until"], name: "index_medical_shift_recurrences_on_last_generated_until" + t.index ["user_id", "deleted_at"], name: "index_medical_shift_recurrences_on_user_id_and_deleted_at" + t.index ["user_id"], name: "index_medical_shift_recurrences_on_user_id" + end + create_table "medical_shifts", force: :cascade do |t| t.string "workload", null: false t.date "start_date", null: false @@ -109,7 +130,9 @@ t.string "hospital_name", default: "", null: false t.time "start_hour", null: false t.datetime "deleted_at" + t.bigint "medical_shift_recurrence_id" t.index ["deleted_at"], name: "index_medical_shifts_on_deleted_at" + t.index ["medical_shift_recurrence_id"], name: "index_medical_shifts_on_medical_shift_recurrence_id" t.index ["paid"], name: "index_medical_shifts_on_paid" t.index ["start_date"], name: "index_medical_shifts_on_start_date" t.index ["user_id"], name: "index_medical_shifts_on_user_id" @@ -193,6 +216,8 @@ add_foreign_key "event_procedures", "patients" add_foreign_key "event_procedures", "procedures" add_foreign_key "event_procedures", "users" + add_foreign_key "medical_shift_recurrences", "users" + add_foreign_key "medical_shifts", "medical_shift_recurrences" add_foreign_key "medical_shifts", "users" add_foreign_key "patients", "users" add_foreign_key "port_values", "cbhpms" diff --git a/spec/factories/medical_shift_recurrences.rb b/spec/factories/medical_shift_recurrences.rb new file mode 100644 index 0000000..e7ad604 --- /dev/null +++ b/spec/factories/medical_shift_recurrences.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :medical_shift_recurrence do + user + + frequency { "weekly" } + day_of_week { 3 } # quarta-feira + start_date { Time.zone.today } + workload { MedicalShifts::Workloads::SIX } + start_hour { "19:00:00" } + hospital_name { create(:hospital).name } + amount_cents { 120_000 } + + trait :weekly do + frequency { "weekly" } + day_of_week { 1 } # segunda + day_of_month { nil } + end + + trait :biweekly do + frequency { "biweekly" } + day_of_week { 5 } # sexta + day_of_month { nil } + end + + trait :monthly_fixed_day do + frequency { "monthly_fixed_day" } + day_of_week { nil } + day_of_month { 15 } + end + + trait :with_end_date do + end_date { 6.months.from_now.to_date } + end + + trait :deleted do + after(:create) do |recurrence| + recurrence.destroy + end + end + end +end diff --git a/spec/factories/medical_shifts.rb b/spec/factories/medical_shifts.rb index 0be37b5..8d32dad 100644 --- a/spec/factories/medical_shifts.rb +++ b/spec/factories/medical_shifts.rb @@ -12,5 +12,9 @@ paid { false } traits_for_enum(:workload, MedicalShifts::Workloads.list) + + trait :with_recurrence do + medical_shift_recurrence + end end end diff --git a/spec/models/medical_shift_recurrence_spec.rb b/spec/models/medical_shift_recurrence_spec.rb new file mode 100644 index 0000000..83afd51 --- /dev/null +++ b/spec/models/medical_shift_recurrence_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftRecurrence do + describe "associations" do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:medical_shifts).dependent(:nullify) } + end + + describe "validations" do + it { is_expected.to validate_presence_of(:frequency) } + it { is_expected.to validate_inclusion_of(:frequency).in_array(MedicalShiftRecurrence::FREQUENCIES) } + it { is_expected.to validate_presence_of(:start_date) } + it { is_expected.to validate_presence_of(:workload) } + it { is_expected.to validate_presence_of(:start_hour) } + it { is_expected.to validate_presence_of(:hospital_name) } + it { is_expected.to validate_presence_of(:amount_cents) } + + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than_or_equal_to(0) } + + context "when frequency is weekly" do + it "requires day_of_week to be present and valid" do + weekly = build(:medical_shift_recurrence, :weekly, day_of_week: nil) + + expect(weekly).not_to be_valid + expect(weekly.errors[:day_of_week]).to include("can't be blank") + end + + it "validates day_of_week is between 0 and 6" do + weekly = build(:medical_shift_recurrence, :weekly, day_of_week: 7) + + expect(weekly).not_to be_valid + expect(weekly.errors[:day_of_week]).to include("must be in 0..6") + end + + it "does not allow day_of_month to be present" do + weekly = build(:medical_shift_recurrence, :weekly, day_of_month: 15) + + expect(weekly).not_to be_valid + expect(weekly.errors[:day_of_month]).to include("It must be empty for weekly/biweekly recurrence.") + end + end + + context "when frequency is biweekly" do + it "requires day_of_week to be present and valid" do + biweekly = build(:medical_shift_recurrence, :biweekly, day_of_week: nil) + + expect(biweekly).not_to be_valid + expect(biweekly.errors[:day_of_week]).to include("can't be blank") + end + end + + context "when frequency is monthly_fixed_day" do + it "requires day_of_month to be present and valid" do + monthly = build(:medical_shift_recurrence, :monthly_fixed_day, day_of_month: nil) + + expect(monthly).not_to be_valid + expect(monthly.errors[:day_of_month]).to include("can't be blank") + end + + it "validates day_of_month is between 1 and 31" do + monthly = build(:medical_shift_recurrence, :monthly_fixed_day, day_of_month: 32) + + expect(monthly).not_to be_valid + expect(monthly.errors[:day_of_month]).to include("must be in 1..31") + end + + it "does not allow day_of_week to be present" do + monthly = build(:medical_shift_recurrence, :monthly_fixed_day, day_of_week: 2) + + expect(monthly).not_to be_valid + expect(monthly.errors[:day_of_week]).to include("It must be empty for monthly_fixed_day recurrence.") + end + end + end + + describe ".enumerations" do + it "has enumerations for workload" do + expect(described_class.enumerations).to include(workload: MedicalShifts::Workloads) + end + end + + describe "monetization" do + it "monetizes amount attribute" do + medical_shift_recurrence = described_class.new(amount_cents: 10) + + expect(medical_shift_recurrence.amount).to eq Money.new(10, "BRL") + expect(medical_shift_recurrence.amount.format).to eq "R$0.10" + end + end +end diff --git a/spec/models/medical_shift_spec.rb b/spec/models/medical_shift_spec.rb index 4421ebd..8f57219 100644 --- a/spec/models/medical_shift_spec.rb +++ b/spec/models/medical_shift_spec.rb @@ -12,6 +12,7 @@ describe "associations" do it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:medical_shift_recurrence).optional } end describe "validations" do @@ -85,4 +86,22 @@ it { expect(shift).to eq("#{medical_shift.hospital_name} | 24h | Nighttime") } end end + + describe ".recurring?" do + context "when medical shift has a recurrence" do + it "returns true" do + medical_shift = create(:medical_shift, :with_recurrence) + + expect(medical_shift.recurring?).to be true + end + end + + context "when medical shift does not have a recurrence" do + it "returns false" do + medical_shift = create(:medical_shift) + + expect(medical_shift.recurring?).to be false + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1dff491..ee8de51 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,6 +14,7 @@ describe "associations" do it { is_expected.to have_many(:event_procedures).dependent(:destroy) } it { is_expected.to have_many(:medical_shifts).dependent(:destroy) } + it { is_expected.to have_many(:medical_shift_recurrences).dependent(:destroy) } it { is_expected.to have_many(:patients).dependent(:destroy) } it { is_expected.to have_many(:procedures).dependent(:destroy) } it { is_expected.to have_many(:health_insurances).dependent(:destroy) } diff --git a/spec/operations/event_procedures/list_spec.rb b/spec/operations/event_procedures/list_spec.rb index 4baaa45..2050528 100644 --- a/spec/operations/event_procedures/list_spec.rb +++ b/spec/operations/event_procedures/list_spec.rb @@ -3,7 +3,6 @@ require "rails_helper" RSpec.describe EventProcedures::List, type: :operation do - describe ".result" do it "is successful" do result = described_class.result(scope: EventProcedure.all, params: {}) diff --git a/spec/operations/medical_shift_recurrences/cancel_spec.rb b/spec/operations/medical_shift_recurrences/cancel_spec.rb new file mode 100644 index 0000000..b0307f8 --- /dev/null +++ b/spec/operations/medical_shift_recurrences/cancel_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +# spec/operations/medical_shift_recurrences/cancel_spec.rb +require "rails_helper" + +RSpec.describe MedicalShiftRecurrences::Cancel, type: :operation do + describe ".result" do + let(:user) { create(:user) } + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow + ) + end + + before do + result = MedicalShiftRecurrences::Create.result( + attributes: recurrence.attributes.slice( + "frequency", "day_of_week", "start_date", "workload", + "start_hour", "hospital_name", "amount_cents" + ), + user_id: user.id + ) + + result.shifts_created.each do |shift| + shift.update!(medical_shift_recurrence: recurrence) + end + end + + context "with valid recurrence" do + it "is successful" do + result = described_class.result(medical_shift_recurrence: recurrence) + + expect(result.success?).to be true + end + + it "soft deletes the recurrence" do + described_class.result(medical_shift_recurrence: recurrence) + + expect(recurrence.reload.deleted?).to be true + end + + it "sets deleted_at timestamp" do + described_class.result(medical_shift_recurrence: recurrence) + + expect(recurrence.reload.deleted_at).to be_present + end + + it "soft deletes future shifts" do + future_shifts_count = recurrence.medical_shifts + .where("start_date >= ?", Date.current) + .count + + expect(future_shifts_count).to be > 0 + + described_class.result(medical_shift_recurrence: recurrence) + + active_future_shifts = MedicalShift + .where(medical_shift_recurrence: recurrence) + .where("start_date >= ?", Date.current) + .count + + expect(active_future_shifts).to eq(0) + end + + it "returns the number of shifts cancelled" do + future_shifts_count = recurrence.medical_shifts + .where("start_date >= ?", Date.current) + .count + + result = described_class.result(medical_shift_recurrence: recurrence) + + expect(result.shifts_cancelled).to eq(future_shifts_count) + end + + it "does not delete past shifts" do + # Criar um shift no passado + past_shift = create( + :medical_shift, + user: user, + medical_shift_recurrence: recurrence, + start_date: 1.week.ago.to_date + ) + + described_class.result(medical_shift_recurrence: recurrence) + + expect(past_shift.reload.deleted?).to be false + end + + it "wraps everything in a transaction" do + # Simular erro no destroy + allow(recurrence).to receive(:destroy).and_raise(ActiveRecord::RecordInvalid) + + expect do + described_class.result(medical_shift_recurrence: recurrence) + end.to raise_error(ActiveRecord::RecordInvalid) + + # Shifts não devem ter sido deletados + active_shifts = MedicalShift + .where(medical_shift_recurrence: recurrence) + .where("start_date >= ?", Date.current) + .count + + expect(active_shifts).to be > 0 + end + end + + context "when recurrence is already deleted" do + before do + recurrence.destroy + end + + it "fails" do + result = described_class.result(medical_shift_recurrence: recurrence) + + expect(result).to be_failure + end + + it "returns already cancelled error" do + result = described_class.result(medical_shift_recurrence: recurrence) + + expect(result.error).to eq("Recurrence already cancelled") + end + + it "does not try to delete shifts" do + expect do + described_class.result(medical_shift_recurrence: recurrence) + end.not_to change(MedicalShift.with_deleted, :count) + end + end + + context "when recurrence has no future shifts" do + let(:recurrence_without_shifts) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Date.tomorrow + ) + end + + it "is successful" do + result = described_class.result( + medical_shift_recurrence: recurrence_without_shifts + ) + + expect(result.success?).to be true + end + + it "returns zero shifts cancelled" do + result = described_class.result( + medical_shift_recurrence: recurrence_without_shifts + ) + + expect(result.shifts_cancelled).to eq(0) + end + + it "still soft deletes the recurrence" do + described_class.result( + medical_shift_recurrence: recurrence_without_shifts + ) + + expect(recurrence_without_shifts.reload.deleted?).to be true + end + end + + context "when some shifts are already deleted" do + before do + # Deletar alguns shifts + recurrence.medical_shifts.first(2).each(&:destroy) + end + + it "only counts non-deleted future shifts" do + remaining_future_shifts = recurrence.medical_shifts + .where("start_date >= ?", Date.current) + .count + + result = described_class.result(medical_shift_recurrence: recurrence) + + expect(result.shifts_cancelled).to eq(remaining_future_shifts) + end + end + end +end diff --git a/spec/operations/medical_shift_recurrences/create_spec.rb b/spec/operations/medical_shift_recurrences/create_spec.rb new file mode 100644 index 0000000..93a7a47 --- /dev/null +++ b/spec/operations/medical_shift_recurrences/create_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftRecurrences::Create, type: :operation do + describe ".result" do + let(:user) { create(:user) } + + context "with valid attributes" do + context "when creating weekly recurrence" do + let(:attributes) do + { + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.today, + workload: "six", + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + end + + it "is successful" do + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.success?).to be true + end + + it "creates a new medical shift recurrence" do + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.medical_shift_recurrence).to be_persisted + expect(result.medical_shift_recurrence.frequency).to eq("weekly") + expect(result.medical_shift_recurrence.day_of_week).to eq(1) + end + + it "generates shifts immediately" do + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.shifts_created).not_to be_empty + expect(result.shifts_created.count).to be >= 8 + end + + it "generates shifts for 4 months ahead" do + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.medical_shift_recurrence.last_generated_until) + .to eq(2.months.from_now.to_date) + end + + it "links shifts to recurrence" do + result = described_class.result(attributes: attributes, user_id: user.id) + + result.shifts_created.each do |shift| + expect(shift.medical_shift_recurrence).to eq(result.medical_shift_recurrence) + end + end + + it "copies attributes to generated shifts" do # rubocop:disable RSpec/MultipleExpectations + result = described_class.result(attributes: attributes, user_id: user.id) + + result.shifts_created.each do |shift| + expect(shift.user_id).to eq(user.id) + expect(shift.workload).to eq("six") + expect(shift.hospital_name).to eq("Hospital Teste") + expect(shift.amount_cents).to eq(120_000) + expect(shift.paid).to be false + end + end + end + + context "when creating biweekly recurrence" do + let(:attributes) do + { + frequency: "biweekly", + day_of_week: 5, + start_date: Date.current, + workload: "twenty_four", + start_hour: "07:00", + hospital_name: "Hospital Central", + amount_cents: 200_000 + } + end + + it "is successful" do + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.success?).to be true + end + + it "generates shifts every two weeks" do + result = described_class.result(attributes: attributes, user_id: user.id) + + dates = result.shifts_created.map(&:start_date).sort + + dates.each_cons(2) do |date1, date2| + expect(date2 - date1).to eq(14) + end + end + + it "skips the start_date if it matches day_of_week" do + start_date = Date.current.next_occurring(:friday) + attributes[:start_date] = start_date + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.shifts_created.first.start_date).to eq(start_date + 14.days) + end + end + + context "when creating monthly_fixed_day recurrence" do + let(:attributes) do + { + frequency: "monthly_fixed_day", + day_of_month: 15, + start_date: Time.zone.today, + workload: "twelve", + start_hour: "19:00", + hospital_name: "Hospital Regional", + amount_cents: 150_000 + } + end + + it "is successful" do + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.success?).to be true + end + + it "generates shifts on the same day each month" do + result = described_class.result(attributes: attributes, user_id: user.id) + + result.shifts_created.each do |shift| + expect(shift.start_date.day).to eq(15) + end + end + end + + context "with end_date" do + let(:attributes) do + { + frequency: "weekly", + day_of_week: 3, + start_date: Time.zone.tomorrow, + end_date: 2.months.from_now.to_date, + workload: MedicalShifts::Workloads::TWELVE, + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + end + + it "does not generate shifts after end_date" do + result = described_class.result(attributes: attributes, user_id: user.id) + + result.shifts_created.each do |shift| + expect(shift.start_date).to be <= attributes[:end_date] + end + end + end + end + + context "with invalid attributes" do + it "fails when frequency is missing" do + attributes = { + day_of_week: 1, + start_date: Date.tomorrow, + workload: "12h", + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result).to be_failure + end + + it "does not create a new medical shift recurrence" do + attributes = { frequency: "weekly", start_date: Date.tomorrow } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.medical_shift_recurrence).not_to be_persisted + expect(result.medical_shift_recurrence).not_to be_valid + end + + it "returns an error for weekly without day_of_week" do + attributes = { + frequency: "weekly", + start_date: Date.tomorrow, + workload: MedicalShifts::Workloads::TWELVE, + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to eq( + ["Day of week can't be blank", "Day of week is not a number"] + ) + end + + it "returns an error for weekly with monthly_fixed_day attribute" do + attributes = { + frequency: "weekly", + day_of_month: 15, + start_date: Date.tomorrow, + workload: "12h", + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to include( + "Day of month It must be empty for weekly/biweekly recurrence." + ) + end + + it "returns an error for monthly_fixed_day with with day_of_week attribute" do + attributes = { + frequency: "monthly_fixed_day", + day_of_week: 2, + start_date: Date.tomorrow, + workload: "12h", + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to include( + "Day of week It must be empty for monthly_fixed_day recurrence." + ) + end + + it "returns an error for monthly_fixed_day without day_of_month" do + attributes = { + frequency: "monthly_fixed_day", + start_date: Date.tomorrow, + workload: MedicalShifts::Workloads::TWELVE, + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to eq( + ["Day of month can't be blank", "Day of month is not a number"] + ) + end + + it "returns an error when start_date is in the past" do + attributes = { + frequency: "weekly", + day_of_week: 1, + start_date: Date.yesterday, + workload: "12h", + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to include( + match(/Start date/) + ) + end + + it "returns an error when end_date is before start_date" do + attributes = { + frequency: "weekly", + day_of_week: 1, + start_date: Date.tomorrow, + end_date: Date.current, + workload: "12h", + start_hour: "19:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to include( + match(/End date/) + ) + end + + it "returns validation errors for missing required fields" do + attributes = { frequency: "weekly", day_of_week: 1 } + + result = described_class.result(attributes: attributes, user_id: user.id) + + expect(result.error).to be_present + expect(result.medical_shift_recurrence.errors.full_messages).to include( + match(/Start date/), + match(/Workload/), + match(/Start hour/), + match(/Hospital name/) + ) + end + end + end +end diff --git a/spec/operations/medical_shift_recurrences/generate_pending_spec.rb b/spec/operations/medical_shift_recurrences/generate_pending_spec.rb new file mode 100644 index 0000000..09c95f2 --- /dev/null +++ b/spec/operations/medical_shift_recurrences/generate_pending_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftRecurrences::GeneratePending, type: :operation do + describe ".result" do + let(:user) { create(:user) } + + context "with recurrences that need generation" do + let!(:recurrence_without_generation) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow, + last_generated_until: nil + ) + end + + let!(:recurrence_with_old_generation) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 3, + start_date: Time.zone.tomorrow, + last_generated_until: 1.month.from_now.to_date + ) + end + + let!(:up_to_date_recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 5, + start_date: Time.zone.tomorrow, + last_generated_until: 6.months.from_now.to_date + ) + end + + let!(:deleted_recurrence) do + create( + :medical_shift_recurrence, :deleted, + user: user, + frequency: "weekly", + day_of_week: 2, + start_date: Time.zone.tomorrow, + last_generated_until: nil + ) + end + + it "is successful" do + result = described_class.result + + expect(result.success?).to be true + end + + it "processes recurrences that need generation" do + result = described_class.result + + expect(result.processed).to eq(2) + end + + it "generates shifts for recurrences without generation" do + _result = described_class.result + + expect(recurrence_without_generation.reload.medical_shifts.count).to be > 0 + end + + it "generates additional shifts for recurrences with old generation" do + initial_count = recurrence_with_old_generation.medical_shifts.count + + _result = described_class.result + + expect(recurrence_with_old_generation.reload.medical_shifts.count).to be > initial_count + end + + it "does not process up-to-date recurrences" do + _result = described_class.result + + expect(up_to_date_recurrence.reload.last_generated_until) + .to eq(6.months.from_now.to_date) + end + + it "does not process deleted recurrences" do + _result = described_class.result + + expect(deleted_recurrence.reload.medical_shifts.count).to eq(0) + end + + it "returns the total number of shifts created" do + result = described_class.result + + expect(result.shifts_created).to be > 0 + end + + it "updates last_generated_until for processed recurrences" do + target_date = 4.months.from_now.to_date + + _result = described_class.result(target_date: target_date) + + expect(recurrence_without_generation.reload.last_generated_until).to eq(target_date) + expect(recurrence_with_old_generation.reload.last_generated_until).to eq(target_date) + end + + it "returns empty errors array when all succeed" do + result = described_class.result + + expect(result.errors).to be_empty + end + end + + context "with custom target_date" do + let!(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow, + last_generated_until: nil + ) + end + + it "generates shifts until the specified target_date" do + target_date = 2.months.from_now.to_date + + _result = described_class.result(target_date: target_date) + + expect(recurrence.reload.last_generated_until).to eq(target_date) + end + end + + context "when errors occur during generation" do + let!(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow, + last_generated_until: nil + ) + end + + before do + allow(MedicalShifts::Create).to receive(:call) + .and_raise(StandardError.new("Database connection failed")) + end + + it "is still successful" do + result = described_class.result + + expect(result.success?).to be true + end + + it "captures errors" do + result = described_class.result + + expect(result.errors).not_to be_empty + expect(result.errors.first[:recurrence_id]).to eq(recurrence.id) + expect(result.errors.first[:error]).to include("Database connection failed") + end + end + + context "with no recurrences needing generation" do + it "is successful" do + result = described_class.result + + expect(result.success?).to be true + end + + it "processes zero recurrences" do + result = described_class.result + + expect(result.processed).to eq(0) + end + + it "creates zero shifts" do + result = described_class.result + + expect(result.shifts_created).to eq(0) + end + + it "has no errors" do + result = described_class.result + + expect(result.errors).to be_empty + end + end + + context "with recurrence ending before target_date" do + let!(:recurrence_with_end_date) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow, + end_date: 1.month.from_now.to_date, + last_generated_until: nil + ) + end + + it "does not generate shifts beyond end_date" do + _result = described_class.result(target_date: 6.months.from_now.to_date) + + recurrence_with_end_date.reload.medical_shifts.each do |shift| + expect(shift.start_date).to be <= recurrence_with_end_date.end_date + end + end + end + end +end diff --git a/spec/policies/medical_shift_recurrence_policy_spec.rb b/spec/policies/medical_shift_recurrence_policy_spec.rb new file mode 100644 index 0000000..51ad14d --- /dev/null +++ b/spec/policies/medical_shift_recurrence_policy_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftRecurrencePolicy do + let(:user) { create(:user) } + let(:medical_shift_recurrence) { create(:medical_shift_recurrence, user: user) } + let(:other_user_recurrence) { create(:medical_shift_recurrence) } + + describe "Scope" do + subject(:policy_scope) do + described_class::Scope.new(current_user, MedicalShiftRecurrence.all).resolve + end + + context "when user is present" do + let(:current_user) { user } + + before do + medical_shift_recurrence + other_user_recurrence + end + + it "returns only current user recurrences" do + expect(policy_scope).to eq([medical_shift_recurrence]) + end + + it "does not return other users recurrences" do + expect(policy_scope).not_to include(other_user_recurrence) + end + end + + context "when user is nil" do + let(:current_user) { nil } + + before do + medical_shift_recurrence + other_user_recurrence + end + + it "returns empty relation" do + expect(policy_scope).to be_empty + end + end + + context "with deleted recurrences" do + let(:current_user) { user } + let!(:deleted_recurrence) do + create(:medical_shift_recurrence, :deleted, user: user) + end + + before do + medical_shift_recurrence + end + + it "does not include deleted recurrences by default" do + expect(policy_scope).to include(medical_shift_recurrence) + expect(policy_scope).not_to include(deleted_recurrence) + end + + it "can include deleted recurrences when using with_deleted" do + scope_with_deleted = described_class::Scope.new( + current_user, + MedicalShiftRecurrence.with_deleted + ).resolve + + expect(scope_with_deleted).to include(medical_shift_recurrence, deleted_recurrence) + end + end + end + + describe "permissions" do + context "when user is present" do + subject { described_class.new(user, MedicalShiftRecurrence) } + + it { is_expected.to permit_actions(%i[index create]) } + end + + context "when user is nil" do + subject { described_class.new(nil, MedicalShiftRecurrence) } + + it { is_expected.to forbid_actions(%i[index create]) } + end + end +end diff --git a/spec/queries/medical_shift_recurrences/needs_generation_query_spec.rb b/spec/queries/medical_shift_recurrences/needs_generation_query_spec.rb new file mode 100644 index 0000000..08ba06a --- /dev/null +++ b/spec/queries/medical_shift_recurrences/needs_generation_query_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftRecurrences::NeedsGenerationQuery do + let(:user) { create(:user) } + let(:target_date) { 2.months.from_now.to_date } + + describe "#call" do + context "with recurrences that need generation" do + let!(:never_generated) do + create( + :medical_shift_recurrence, + user: user, + last_generated_until: nil + ) + end + + let!(:old_generation) do + create( + :medical_shift_recurrence, + user: user, + last_generated_until: 1.month.from_now.to_date + ) + end + + let!(:up_to_date) do + create( + :medical_shift_recurrence, + user: user, + last_generated_until: 2.months.from_now.to_date + ) + end + + let!(:deleted_recurrence) do + create( + :medical_shift_recurrence, :deleted, + user: user, + last_generated_until: nil + ) + end + + it "includes recurrences never generated" do + result = described_class.call(target_date: target_date) + + expect(result).to include(never_generated) + end + + it "includes recurrences with old generation" do + result = described_class.call(target_date: target_date) + + expect(result).to include(old_generation) + end + + it "excludes up-to-date recurrences" do + result = described_class.call(target_date: target_date) + + expect(result).not_to include(up_to_date) + end + + it "excludes deleted recurrences" do + result = described_class.call(target_date: target_date) + + expect(result).not_to include(deleted_recurrence) + end + + it "returns correct count" do + result = described_class.call(target_date: target_date) + + expect(result.count).to eq(2) + end + end + + context "with different target dates" do + let!(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + last_generated_until: 2.months.from_now.to_date + ) + end + + it "includes recurrence when target_date is after last_generated_until" do + target = 3.months.from_now.to_date + result = described_class.call(target_date: target) + + expect(result).to include(recurrence) + end + + it "excludes recurrence when target_date is before last_generated_until" do + target = 1.month.from_now.to_date + result = described_class.call(target_date: target) + + expect(result).not_to include(recurrence) + end + + it "excludes recurrence when target_date equals last_generated_until" do + target = recurrence.last_generated_until + result = described_class.call(target_date: target) + + expect(result).not_to include(recurrence) + end + end + + context "with custom relation" do + let!(:user1_recurrence) do + create( + :medical_shift_recurrence, + user: user, + last_generated_until: nil + ) + end + + let!(:user2_recurrence) do + other_user = create(:user) + create( + :medical_shift_recurrence, + user: other_user, + last_generated_until: nil + ) + end + + it "respects the provided relation scope" do + user_scope = MedicalShiftRecurrence.where(user: user) + result = described_class.call(target_date: target_date, relation: user_scope) + + expect(result).to include(user1_recurrence) + expect(result).not_to include(user2_recurrence) + end + end + + context "with no recurrences" do + it "returns empty relation" do + result = described_class.call(target_date: target_date) + + expect(result).to be_empty + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 524c758..f1573a4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -75,7 +75,7 @@ Rails.root.glob("spec/support/**/*.rb").each { |f| require f } # Clears Rack:Attack cache between specs - config.before(:each) do + config.before do Rack::Attack.cache.store.clear if Rack::Attack.cache.store.respond_to?(:clear) end end diff --git a/spec/requests/api/v1/medical_shift_recurrences_request_spec.rb b/spec/requests/api/v1/medical_shift_recurrences_request_spec.rb new file mode 100644 index 0000000..6d1413f --- /dev/null +++ b/spec/requests/api/v1/medical_shift_recurrences_request_spec.rb @@ -0,0 +1,643 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "MedicalShiftRecurrences" do + let(:user) { create(:user) } + let(:auth_headers) { auth_token_for(user) } + + describe "GET /api/v1/medical_shift_recurrences" do + let!(:user_recurrence_one) do + create( + :medical_shift_recurrence, + user: user, + created_at: 2.days.ago + ) + end + + let!(:user_recurrence_two) do + create( + :medical_shift_recurrence, + user: user, + created_at: 1.day.ago + ) + end + + let!(:other_user_recurrence) do + create( + :medical_shift_recurrence, + created_at: 3.days.ago + ) + end + + let!(:deleted_recurrence) do + create( + :medical_shift_recurrence, :deleted, + user: user + ) + end + + context "with authentication" do + it "returns ok status" do + get "/api/v1/medical_shift_recurrences", headers: auth_headers + + expect(response).to have_http_status(:ok) + end + + it "returns only current user recurrences" do + get "/api/v1/medical_shift_recurrences", headers: auth_headers + + body = response.parsed_body + ids = body.pluck(:id) + + expect(ids).to include(user_recurrence_one.id, user_recurrence_two.id) + expect(ids).not_to include(other_user_recurrence.id) + end + + it "does not return deleted recurrences" do + get "/api/v1/medical_shift_recurrences", headers: auth_headers + + body = response.parsed_body + ids = body.pluck(:id) + + expect(ids).not_to include(deleted_recurrence.id) + end + + it "returns recurrences ordered by created_at desc" do + get "/api/v1/medical_shift_recurrences", headers: auth_headers + + body = response.parsed_body + + expect(body.first["id"]).to eq(user_recurrence_two.id) + expect(body.second["id"]).to eq(user_recurrence_one.id) + end + + it "returns recurrences with correct attributes" do + get "/api/v1/medical_shift_recurrences", headers: auth_headers + + body = response.parsed_body + first_recurrence = body.first + + expect(first_recurrence).to include( + "id", + "frequency", + "start_date", + "workload", + "hospital_name", + "amount_cents" + ) + end + + it "returns empty array when user has no recurrences" do + user_without_recurrences = create(:user) + headers = auth_token_for(user_without_recurrences) + + get "/api/v1/medical_shift_recurrences", headers: headers + + body = response.parsed_body + + expect(body).to be_empty + end + end + + context "without authentication" do + it "returns unauthorized status" do + get "/api/v1/medical_shift_recurrences" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "POST /api/v1/medical_shift_recurrences" do + context "with valid params" do + context "when creating weekly recurrence" do + let(:valid_params) do + { + medical_shift_recurrence: { + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "returns created status" do + post "/api/v1/medical_shift_recurrences", + params: valid_params, + headers: auth_headers + + expect(response).to have_http_status(:created) + end + + it "creates a medical shift recurrence" do + expect do + post "/api/v1/medical_shift_recurrences", + params: valid_params, + headers: auth_headers + end.to change(MedicalShiftRecurrence, :count).by(1) + end + + it "returns the created recurrence" do + post "/api/v1/medical_shift_recurrences", + params: valid_params, + headers: auth_headers + + body = response.parsed_body + + expect(body["medical_shift_recurrence"]).to be_present + expect(body["medical_shift_recurrence"]["frequency"]).to eq("weekly") + expect(body["medical_shift_recurrence"]["day_of_week"]).to eq(1) + end + + it "generates shifts immediately" do + post "/api/v1/medical_shift_recurrences", + params: valid_params, + headers: auth_headers + + body = response.parsed_body + + expect(body["shifts_generated"]).to be > 0 + end + + it "creates shifts for the recurrence" do + expect do + post "/api/v1/medical_shift_recurrences", + params: valid_params, + headers: auth_headers + end.to change(MedicalShift, :count).by_at_least(8) + end + + it "assigns recurrence to current user" do + post "/api/v1/medical_shift_recurrences", + params: valid_params, + headers: auth_headers + + recurrence = MedicalShiftRecurrence.last + + expect(recurrence.user).to eq(user) + end + end + + context "when creating biweekly recurrence" do + let(:biweekly_params) do + { + medical_shift_recurrence: { + frequency: "biweekly", + day_of_week: 5, + start_date: Time.zone.tomorrow, + workload: MedicalShifts::Workloads::TWELVE, + start_hour: "07:00:00", + hospital_name: "Hospital Central", + amount_cents: 200_000 + } + } + end + + it "creates biweekly recurrence successfully" do + post "/api/v1/medical_shift_recurrences", + params: biweekly_params, + headers: auth_headers + + expect(response).to have_http_status(:created) + + body = response.parsed_body + expect(body["medical_shift_recurrence"]["frequency"]).to eq("biweekly") + end + end + + context "when creating monthly_fixed_day recurrence" do + let(:monthly_params) do + { + medical_shift_recurrence: { + frequency: "monthly_fixed_day", + day_of_month: 15, + start_date: Time.zone.tomorrow, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Regional", + amount_cents: 150_000 + } + } + end + + it "creates monthly recurrence successfully" do + post "/api/v1/medical_shift_recurrences", + params: monthly_params, + headers: auth_headers + + expect(response).to have_http_status(:created) + + body = response.parsed_body + expect(body["medical_shift_recurrence"]["frequency"]).to eq("monthly_fixed_day") + expect(body["medical_shift_recurrence"]["day_of_month"]).to eq(15) + end + end + + context "with end_date" do + let(:params_with_end_date) do + { + medical_shift_recurrence: { + frequency: "weekly", + day_of_week: 3, + start_date: Time.zone.tomorrow, + end_date: 2.months.from_now.to_date, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "creates recurrence with end_date" do + post "/api/v1/medical_shift_recurrences", + params: params_with_end_date, + headers: auth_headers + + expect(response).to have_http_status(:created) + + body = response.parsed_body + expect(body["medical_shift_recurrence"]["end_date"]).to be_present + end + end + end + + context "with invalid params" do + context "when frequency is missing" do + let(:invalid_params) do + { + medical_shift_recurrence: { + day_of_week: 1, + start_date: Time.zone.tomorrow, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "returns unprocessable entity status" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it "does not create a recurrence" do + expect do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + end.not_to change(MedicalShiftRecurrence, :count) + end + + it "returns error messages" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + body = response.parsed_body + expect(body["errors"]).to be_present + end + end + + context "when day_of_week is missing for weekly frequency" do + let(:invalid_params) do + { + medical_shift_recurrence: { + frequency: "weekly", + start_date: Time.zone.tomorrow, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "returns unprocessable content status" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it "returns validation error" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + body = response.parsed_body + expect(body["errors"]).to include(match(/Day of week/)) + end + end + + context "when day_of_month is missing for monthly_fixed_day frequency" do + let(:invalid_params) do + { + medical_shift_recurrence: { + frequency: "monthly_fixed_day", + start_date: Time.zone.tomorrow, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "returns unprocessable content status" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it "returns validation error" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + body = response.parsed_body + expect(body["errors"]).to include(match(/Day of month/)) + end + end + + context "when start_date is in the past" do + let(:invalid_params) do + { + medical_shift_recurrence: { + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.yesterday, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "returns unprocessable content status" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it "returns validation error" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + body = response.parsed_body + expect(body["errors"]).to include(match(/Start date/)) + end + end + + context "when end_date is before start_date" do + let(:invalid_params) do + { + medical_shift_recurrence: { + frequency: "weekly", + day_of_week: 1, + start_date: Time.zone.tomorrow, + end_date: 1.month.ago.to_date, + workload: MedicalShifts::Workloads::SIX, + start_hour: "19:00:00", + hospital_name: "Hospital Teste", + amount_cents: 120_000 + } + } + end + + it "returns unprocessable content status" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it "returns validation error" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + body = response.parsed_body + expect(body["errors"]).to include(match(/End date/)) + end + end + + context "when required fields are missing" do + let(:invalid_params) do + { + medical_shift_recurrence: { + frequency: "weekly", + day_of_week: 1 + } + } + end + + it "returns unprocessable content status" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it "returns multiple validation errors" do + post "/api/v1/medical_shift_recurrences", + params: invalid_params, + headers: auth_headers + + body = response.parsed_body + errors = body["errors"] + + expect(errors).to include( + match(/Start date/), + match(/Workload/), + match(/Start hour/), + match(/Hospital name/) + ) + end + end + end + + context "without authentication" do + it "returns unauthorized status" do + post "/api/v1/medical_shift_recurrences", + params: { medical_shift_recurrence: {} } + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "DELETE /api/v1/medical_shift_recurrences/:id" do + let!(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Date.tomorrow + ) + end + + before do + result = MedicalShiftRecurrences::Create.result( + attributes: recurrence.attributes.slice( + "frequency", "day_of_week", "start_date", "workload", + "start_hour", "hospital_name", "amount_cents" + ), + user_id: user.id + ) + + result.shifts_created.each do |shift| + shift.update!(medical_shift_recurrence: recurrence) + end + end + + context "with valid recurrence" do + it "returns ok status" do + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + + expect(response).to have_http_status(:ok) + end + + it "soft deletes the recurrence" do + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + + expect(recurrence.reload.deleted?).to be true + end + + it "returns success message" do + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + + body = response.parsed_body + + expect(body["message"]).to eq("Recurrence cancelled successfully") + end + + it "returns the number of shifts cancelled" do + future_shifts_count = recurrence.medical_shifts + .where("start_date >= ?", Date.current) + .count + + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + + body = response.parsed_body + + expect(body["shifts_cancelled"]).to eq(future_shifts_count) + end + + it "soft deletes future shifts" do + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + + active_future_shifts = MedicalShift + .where(medical_shift_recurrence: recurrence) + .where("start_date >= ?", Date.current) + .count + + expect(active_future_shifts).to eq(0) + end + end + + context "when recurrence does not exist" do + it "returns not found status" do + delete "/api/v1/medical_shift_recurrences/999999", + headers: auth_headers + + expect(response).to have_http_status(:not_found) + end + + it "returns error message" do + delete "/api/v1/medical_shift_recurrences/999999", + headers: auth_headers + + body = response.parsed_body + + expect(body["error"]).to eq("Recurrence not found") + end + end + + context "when recurrence belongs to another user" do + let(:other_user_recurrence) do + create(:medical_shift_recurrence) + end + + it "returns not found status" do + delete "/api/v1/medical_shift_recurrences/#{other_user_recurrence.id}", + headers: auth_headers + + expect(response).to have_http_status(:not_found) + end + end + + context "when recurrence is already deleted" do + before do + recurrence.destroy + end + + it "returns not found status" do + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + + expect(response).to have_http_status(:not_found) + end + end + + context "without authentication" do + it "returns unauthorized status" do + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when operation fails" do + let!(:recurrence) do + create(:medical_shift_recurrence, user: user) + end + + before do + # rubocop:disable RSpec/VerifiedDoubles + failed_result = double( + success?: false, + error: "Something went wrong" + ) + # rubocop:enable RSpec/VerifiedDoubles + + allow(MedicalShiftRecurrences::Cancel).to receive(:call) + .and_return(failed_result) + + delete "/api/v1/medical_shift_recurrences/#{recurrence.id}", + headers: auth_headers + end + + it { expect(response).to have_http_status(:unprocessable_content) } + + it "returns error message" do + body = response.parsed_body + expect(body["errors"]).to eq("Something went wrong") + end + end + end +end diff --git a/spec/requests/api/v1/medical_shifts_request_spec.rb b/spec/requests/api/v1/medical_shifts_request_spec.rb index 34c7fc0..98e6a05 100644 --- a/spec/requests/api/v1/medical_shifts_request_spec.rb +++ b/spec/requests/api/v1/medical_shifts_request_spec.rb @@ -33,6 +33,7 @@ expect(response.parsed_body["medical_shifts"]).to include( { "id" => medical_shifts.second.id, + "medical_shift_recurrence_id" => medical_shifts.second.medical_shift_recurrence_id.presence, "hospital_name" => medical_shifts.second.hospital_name, "workload" => medical_shifts.second.workload_humanize, "date" => medical_shifts.second.start_date.strftime("%d/%m/%Y"), @@ -44,6 +45,7 @@ }, { "id" => medical_shifts.first.id, + "medical_shift_recurrence_id" => medical_shifts.first.medical_shift_recurrence_id.presence, "hospital_name" => medical_shifts.first.hospital_name, "workload" => medical_shifts.first.workload_humanize, "date" => medical_shifts.first.start_date.strftime("%d/%m/%Y"), @@ -125,6 +127,7 @@ expect(response.parsed_body["medical_shifts"]).to include( { "id" => paid_medical_shifts.second.id, + "medical_shift_recurrence_id" => paid_medical_shifts.second.medical_shift_recurrence_id.presence, "hospital_name" => paid_medical_shifts.second.hospital_name, "workload" => paid_medical_shifts.second.workload_humanize, "date" => paid_medical_shifts.second.start_date.strftime("%d/%m/%Y"), @@ -136,6 +139,7 @@ }, { "id" => paid_medical_shifts.first.id, + "medical_shift_recurrence_id" => paid_medical_shifts.first.medical_shift_recurrence_id.presence, "hospital_name" => paid_medical_shifts.first.hospital_name, "workload" => paid_medical_shifts.first.workload_humanize, "date" => paid_medical_shifts.first.start_date.strftime("%d/%m/%Y"), @@ -164,6 +168,7 @@ expect(response.parsed_body["medical_shifts"]).to include( { "id" => unpaid_medical_shifts.second.id, + "medical_shift_recurrence_id" => unpaid_medical_shifts.second.medical_shift_recurrence_id.presence, "hospital_name" => unpaid_medical_shifts.second.hospital_name, "workload" => unpaid_medical_shifts.second.workload_humanize, "date" => unpaid_medical_shifts.second.start_date.strftime("%d/%m/%Y"), @@ -175,6 +180,7 @@ }, { "id" => unpaid_medical_shifts.first.id, + "medical_shift_recurrence_id" => unpaid_medical_shifts.first.medical_shift_recurrence_id.presence, "hospital_name" => unpaid_medical_shifts.first.hospital_name, "workload" => unpaid_medical_shifts.first.workload_humanize, "date" => unpaid_medical_shifts.first.start_date.strftime("%d/%m/%Y"), diff --git a/spec/services/medical_shift_recurrences/recurrence_date_calculator_service_spec.rb b/spec/services/medical_shift_recurrences/recurrence_date_calculator_service_spec.rb new file mode 100644 index 0000000..eb8c1c8 --- /dev/null +++ b/spec/services/medical_shift_recurrences/recurrence_date_calculator_service_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftRecurrences::RecurrenceDateCalculatorService do + let(:user) { create(:user) } + + describe "#dates_until" do + let(:target_date) { 3.months.from_now.to_date } + + context "with deleted recurrence" do + let(:recurrence) { create(:medical_shift_recurrence, :deleted, user: user) } + + it "returns empty array" do + service = described_class.new(recurrence) + result = service.dates_until(target_date) + + expect(result).to be_empty + end + end + + context "with weekly recurrence" do + let(:start_date) { Date.current.next_occurring(:monday) } + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, # Monday + start_date: start_date + ) + end + + it "generates dates every week" do + service = described_class.new(recurrence) + dates = service.dates_until(2.months.from_now.to_date) + + expect(dates).to all(satisfy { |d| d.wday == 1 }) + end + + it "skips the start_date when it matches day_of_week" do + service = described_class.new(recurrence) + dates = service.dates_until(2.months.from_now.to_date) + + expect(dates.first).to eq(start_date + 7.days) + end + + it "spaces dates 7 days apart" do + service = described_class.new(recurrence) + dates = service.dates_until(2.months.from_now.to_date) + + dates.each_cons(2) do |date1, date2| + expect(date2 - date1).to eq(7) + end + end + + context "when start_date is not the target weekday" do + let(:start_date) { Date.current.next_occurring(:wednesday) } + + it "first date is the next monday after start_date" do + service = described_class.new(recurrence) + dates = service.dates_until(2.months.from_now.to_date) + + expect(dates.first).to be > start_date + expect(dates.first.wday).to eq(1) + end + end + end + + context "with biweekly recurrence" do + let(:start_date) { Date.current.next_occurring(:friday) } + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "biweekly", + day_of_week: 5, # Friday + start_date: start_date + ) + end + + it "generates dates every two weeks" do + service = described_class.new(recurrence) + dates = service.dates_until(3.months.from_now.to_date) + + dates.each_cons(2) do |date1, date2| + expect(date2 - date1).to eq(14) + end + end + + it "skips the start_date and starts 14 days later" do + service = described_class.new(recurrence) + dates = service.dates_until(2.months.from_now.to_date) + + expect(dates.first).to eq(start_date + 14.days) + end + + context "when start_date is not the target weekday" do + let(:start_date) { Date.current.next_occurring(:monday) } + + it "first date is the next friday after start_date" do + service = described_class.new(recurrence) + dates = service.dates_until(2.months.from_now.to_date) + + expect(dates.first).to be > start_date + expect(dates.first.wday).to eq(5) + end + end + end + + context "with monthly_fixed_day recurrence" do + let(:start_date) { Date.new(2026, 1, 15) } + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "monthly_fixed_day", + day_of_week: nil, + day_of_month: 15, + start_date: start_date + ) + end + + it "generates dates on the same day each month" do + service = described_class.new(recurrence) + dates = service.dates_until(Date.new(2026, 6, 30)) + + dates.each do |date| + expect(date.day).to eq(15) + end + end + + it "skips the start_date when it matches day_of_month" do + service = described_class.new(recurrence) + dates = service.dates_until(Date.new(2026, 6, 30)) + + expect(dates.first).to eq(Date.new(2026, 2, 15)) + end + + context "when day is 31" do + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "monthly_fixed_day", + day_of_week: nil, + day_of_month: 31, + start_date: Date.new(2026, 1, 31) + ) + end + + it "skips months without day 31" do + service = described_class.new(recurrence) + dates = service.dates_until(Date.new(2026, 6, 30)) + + months = dates.map(&:month) + + expect(months).to include(3, 5) + expect(months).not_to include(2, 4, 6) + end + end + + context "when start_date day is before target day" do + let(:start_date) { Date.new(2026, 1, 10) } + + it "first date is in the current month" do + service = described_class.new(recurrence) + dates = service.dates_until(Date.new(2026, 6, 30)) + + expect(dates.first).to eq(Date.new(2026, 1, 15)) + end + end + + context "when start_date day is after target day" do + let(:start_date) { Date.new(2026, 1, 20) } + + it "first date is in the next month" do + service = described_class.new(recurrence) + dates = service.dates_until(Date.new(2026, 6, 30)) + + expect(dates.first).to eq(Date.new(2026, 2, 15)) + end + end + end + + context "with end_date" do + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 3, + start_date: Date.tomorrow, + end_date: 1.month.from_now.to_date + ) + end + + it "does not generate dates beyond end_date" do + service = described_class.new(recurrence) + dates = service.dates_until(6.months.from_now.to_date) + + expect(dates).to all(be <= recurrence.end_date) + end + + it "respects end_date over target_date" do + service = described_class.new(recurrence) + dates = service.dates_until(6.months.from_now.to_date) + + expect(dates.last).to be <= 1.month.from_now.to_date + end + end + + context "with last_generated_until set" do + let(:recurrence) do + create( + :medical_shift_recurrence, + user: user, + frequency: "weekly", + day_of_week: 1, + start_date: Date.current.next_occurring(:monday), + last_generated_until: 1.month.from_now.to_date + ) + end + + it "starts from the next occurrence after last_generated_until" do + service = described_class.new(recurrence) + dates = service.dates_until(3.months.from_now.to_date) + + expect(dates.first).to be > recurrence.last_generated_until + end + + it "does not include dates before last_generated_until" do + service = described_class.new(recurrence) + dates = service.dates_until(3.months.from_now.to_date) + + expect(dates).to all(be > recurrence.last_generated_until) + end + end + end +end diff --git a/spec/support/query_helper.rb b/spec/support/query_helper.rb index 230b9e9..7697ccd 100644 --- a/spec/support/query_helper.rb +++ b/spec/support/query_helper.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true module QueryHelper - def count_queries(&block) + def count_queries(&) queries = [] - callback = ->(_name, _start, _finish, _id, payload) do - queries << payload[:sql] unless payload[:name] =~ /SCHEMA|TRANSACTION/ + callback = lambda do |_name, _start, _finish, _id, payload| + queries << payload[:sql] unless /SCHEMA|TRANSACTION/.match?(payload[:name]) end - ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block) + ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &) queries.count end end