From d5d13792a8f9254067076a6663d776a6d8873ef9 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 18 Aug 2025 14:22:27 +0530 Subject: [PATCH] add leave controllers and rspec --- config/routes.rb | 6 + .../api/v1/accounts/leaves_controller.rb | 43 ++ .../app/models/enterprise/concerns/account.rb | 1 + .../app/models/enterprise/concerns/user.rb | 2 + enterprise/app/models/leave.rb | 105 ++++ enterprise/app/policies/leave_policy.rb | 59 ++ .../v1/accounts/leaves/create.json.jbuilder | 3 + .../v1/accounts/leaves/index.json.jbuilder | 5 + .../api/v1/accounts/leaves/show.json.jbuilder | 3 + .../v1/accounts/leaves/update.json.jbuilder | 3 + .../views/api/v1/models/_leave.json.jbuilder | 26 + .../api/v1/accounts/leaves_controller_spec.rb | 550 ++++++++++++++++++ spec/enterprise/models/leave_spec.rb | 288 +++++++++ spec/enterprise/policies/leave_policy_spec.rb | 250 ++++++++ spec/factories/leaves.rb | 43 ++ 15 files changed, 1387 insertions(+) create mode 100644 enterprise/app/controllers/api/v1/accounts/leaves_controller.rb create mode 100644 enterprise/app/models/leave.rb create mode 100644 enterprise/app/policies/leave_policy.rb create mode 100644 enterprise/app/views/api/v1/accounts/leaves/create.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/leaves/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/leaves/show.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/leaves/update.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/_leave.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/leaves_controller_spec.rb create mode 100644 spec/enterprise/models/leave_spec.rb create mode 100644 spec/enterprise/policies/leave_policy_spec.rb create mode 100644 spec/factories/leaves.rb diff --git a/config/routes.rb b/config/routes.rb index 10749062e..f32094ad8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,12 @@ Rails.application.routes.draw do post :execute, on: :member end resources :sla_policies, only: [:index, :create, :show, :update, :destroy] + resources :leaves, only: [:index, :create, :show, :update, :destroy] do + member do + patch :approve + patch :reject + end + end resources :custom_roles, only: [:index, :create, :show, :update, :destroy] resources :campaigns, only: [:index, :create, :show, :update, :destroy] resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] diff --git a/enterprise/app/controllers/api/v1/accounts/leaves_controller.rb b/enterprise/app/controllers/api/v1/accounts/leaves_controller.rb new file mode 100644 index 000000000..596943d0b --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/leaves_controller.rb @@ -0,0 +1,43 @@ +class Api::V1::Accounts::LeavesController < Api::V1::Accounts::EnterpriseAccountsController + before_action :fetch_leave, only: [:show, :update, :destroy, :approve, :reject] + before_action :check_authorization + + def index + @leaves = policy_scope(Current.account.leaves) + end + + def show; end + + def create + @leave = Current.account.leaves.create!(permitted_params.merge(user: current_user)) + end + + def update + @leave.update!(permitted_params) + end + + def destroy + @leave.destroy! + head :ok + end + + def approve + @leave.approve!(current_user) + render :show + end + + def reject + @leave.reject!(current_user) + render :show + end + + private + + def permitted_params + params.require(:leave).permit(:start_date, :end_date, :leave_type, :reason) + end + + def fetch_leave + @leave = Current.account.leaves.find_by(id: params[:id]) + end +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index c31b6c10e..d0aba4f23 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -5,6 +5,7 @@ module Enterprise::Concerns::Account has_many :sla_policies, dependent: :destroy_async has_many :applied_slas, dependent: :destroy_async has_many :custom_roles, dependent: :destroy_async + has_many :leaves, dependent: :destroy_async, class_name: 'Leave' has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' diff --git a/enterprise/app/models/enterprise/concerns/user.rb b/enterprise/app/models/enterprise/concerns/user.rb index 7231decb7..03347848d 100644 --- a/enterprise/app/models/enterprise/concerns/user.rb +++ b/enterprise/app/models/enterprise/concerns/user.rb @@ -6,6 +6,8 @@ module Enterprise::Concerns::User has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable has_many :copilot_threads, dependent: :destroy_async + has_many :leaves, dependent: :destroy_async, class_name: 'Leave' + has_many :approved_leaves, class_name: 'Leave', foreign_key: 'approved_by_id', dependent: :nullify, inverse_of: :approver end def ensure_installation_pricing_plan_quantity diff --git a/enterprise/app/models/leave.rb b/enterprise/app/models/leave.rb new file mode 100644 index 000000000..89deb5202 --- /dev/null +++ b/enterprise/app/models/leave.rb @@ -0,0 +1,105 @@ +# == Schema Information +# +# Table name: leaves +# +# id :bigint not null, primary key +# approved_at :datetime +# end_date :date not null +# leave_type :integer default("annual"), not null +# reason :text +# start_date :date not null +# status :integer default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# approved_by_id :bigint +# user_id :bigint not null +# +# Indexes +# +# index_leaves_on_account_id (account_id) +# index_leaves_on_account_id_and_status (account_id,status) +# index_leaves_on_approved_by_id (approved_by_id) +# index_leaves_on_user_id (user_id) +# +class Leave < ApplicationRecord + belongs_to :account + belongs_to :user + belongs_to :approver, class_name: 'User', foreign_key: 'approved_by_id', optional: true, inverse_of: :approved_leaves + + enum leave_type: { + annual: 0, + sick: 1, + personal: 2, + maternity: 3, + paternity: 4, + emergency: 5, + bereavement: 6, + study: 7, + other: 8 + } + + enum status: { + pending: 0, + approved: 1, + rejected: 2, + cancelled: 3 + } + + validates :start_date, :end_date, presence: true + validates :leave_type, :status, presence: true + validate :end_date_after_start_date + validate :future_dates_for_pending_leaves + validate :approver_is_admin + + scope :for_account, ->(account_id) { where(account_id: account_id) } + scope :for_user, ->(user_id) { where(user_id: user_id) } + scope :by_status, ->(status) { where(status: status) } + scope :by_leave_type, ->(leave_type) { where(leave_type: leave_type) } + scope :in_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) } + + def approve!(approver) + update!(status: :approved, approved_by_id: approver.id, approved_at: Time.current) + end + + def reject!(approver) + update!(status: :rejected, approved_by_id: approver.id, approved_at: Time.current) + end + + def duration_in_days + return 0 unless start_date && end_date + + (end_date - start_date).to_i + 1 + end + + def can_be_cancelled? + pending? || (approved? && start_date > Date.current) + end + + def overlaps_with?(other_leave) + return false unless other_leave.is_a?(Leave) + + start_date <= other_leave.end_date && end_date >= other_leave.start_date + end + + private + + def end_date_after_start_date + return unless start_date && end_date + + errors.add(:end_date, 'must be after start date') if end_date < start_date + end + + def future_dates_for_pending_leaves + return unless pending? + + errors.add(:start_date, 'must be in the future') if start_date && start_date <= Date.current + end + + def approver_is_admin + return unless approved_by_id && approver + + account_user = account.account_users.find_by(user: approver) + errors.add(:approved_by, 'must be an administrator') unless account_user&.administrator? + end +end diff --git a/enterprise/app/policies/leave_policy.rb b/enterprise/app/policies/leave_policy.rb new file mode 100644 index 000000000..4348e46d6 --- /dev/null +++ b/enterprise/app/policies/leave_policy.rb @@ -0,0 +1,59 @@ +class LeavePolicy < ApplicationPolicy + def index? + @account_user.administrator? || @account_user.agent? + end + + def show? + @account_user.administrator? || owned_by_user? + end + + def create? + @account_user.administrator? || @account_user.agent? + end + + def update? + return false unless @record.pending? + + @account_user.administrator? || owned_by_user? + end + + def destroy? + return false unless @record.can_be_cancelled? + + @account_user.administrator? || owned_by_user? + end + + def approve? + @account_user.administrator? + end + + def reject? + @account_user.administrator? + end + + class Scope + attr_reader :user_context, :user, :scope, :account, :account_user + + def initialize(user_context, scope) + @user_context = user_context + @user = user_context[:user] + @account = user_context[:account] + @account_user = user_context[:account_user] + @scope = scope + end + + def resolve + if @account_user.administrator? + scope.includes(:user, :approver) + else + scope.where(user: @user).includes(:user, :approver) + end + end + end + + private + + def owned_by_user? + @record&.user_id == @account_user.user_id + end +end diff --git a/enterprise/app/views/api/v1/accounts/leaves/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/leaves/create.json.jbuilder new file mode 100644 index 000000000..8f59e1a83 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/leaves/create.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/leave', formats: [:json], leave: @leave +end diff --git a/enterprise/app/views/api/v1/accounts/leaves/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/leaves/index.json.jbuilder new file mode 100644 index 000000000..a9d7a2b0e --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/leaves/index.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.array! @leaves do |leave| + json.partial! 'api/v1/models/leave', formats: [:json], leave: leave + end +end diff --git a/enterprise/app/views/api/v1/accounts/leaves/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/leaves/show.json.jbuilder new file mode 100644 index 000000000..8f59e1a83 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/leaves/show.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/leave', formats: [:json], leave: @leave +end diff --git a/enterprise/app/views/api/v1/accounts/leaves/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/leaves/update.json.jbuilder new file mode 100644 index 000000000..8f59e1a83 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/leaves/update.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/leave', formats: [:json], leave: @leave +end diff --git a/enterprise/app/views/api/v1/models/_leave.json.jbuilder b/enterprise/app/views/api/v1/models/_leave.json.jbuilder new file mode 100644 index 000000000..6c641c378 --- /dev/null +++ b/enterprise/app/views/api/v1/models/_leave.json.jbuilder @@ -0,0 +1,26 @@ +json.id leave.id +json.start_date leave.start_date +json.end_date leave.end_date +json.leave_type leave.leave_type +json.status leave.status +json.reason leave.reason +json.duration_in_days leave.duration_in_days +json.approved_at leave.approved_at&.to_i +json.created_at leave.created_at.to_i +json.updated_at leave.updated_at.to_i + +json.user do + json.id leave.user.id + json.name leave.user.name + json.email leave.user.email +end + +if leave.approver.present? + json.approver do + json.id leave.approver.id + json.name leave.approver.name + json.email leave.approver.email + end +else + json.approver nil +end diff --git a/spec/enterprise/controllers/api/v1/accounts/leaves_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/leaves_controller_spec.rb new file mode 100644 index 000000000..e9a6abea2 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/leaves_controller_spec.rb @@ -0,0 +1,550 @@ +require 'rails_helper' + +RSpec.describe 'Leaves API', type: :request do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:other_user) { create(:user, account: account, role: :agent) } + + describe 'GET #index' do + before do + create(:leave, account: account, user: agent) + create(:leave, account: account, user: other_user) + end + + context 'when authenticated as administrator' do + it 'returns all leaves in the account' do + get "/api/v1/accounts/#{account.id}/leaves", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['leaves'].length).to eq(2) + end + end + + context 'when authenticated as agent' do + it 'returns only own leaves' do + get "/api/v1/accounts/#{account.id}/leaves", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['leaves'].length).to eq(1) + expect(body['leaves'][0]['user']['id']).to eq(agent.id) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/leaves" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET #show' do + let(:leave) { create(:leave, account: account, user: agent) } + let(:other_leave) { create(:leave, account: account, user: other_user) } + + context 'when authenticated as administrator' do + it 'shows any leave in the account' do + get "/api/v1/accounts/#{account.id}/leaves/#{leave.id}", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['id']).to eq(leave.id) + end + end + + context 'when authenticated as agent viewing own leave' do + it 'shows the leave' do + get "/api/v1/accounts/#{account.id}/leaves/#{leave.id}", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['id']).to eq(leave.id) + expect(body['user']['id']).to eq(agent.id) + end + end + + context 'when authenticated as agent viewing other user leave' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/leaves/#{other_leave.id}", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when leave does not exist' do + it 'returns not found' do + get "/api/v1/accounts/#{account.id}/leaves/99999", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/leaves/#{leave.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST #create' do + let(:valid_params) do + { + leave: { + start_date: 1.week.from_now.to_date, + end_date: 2.weeks.from_now.to_date, + leave_type: 'annual', + reason: 'Annual vacation' + } + } + end + + context 'when authenticated as administrator' do + it 'creates a leave for current user' do + expect do + post "/api/v1/accounts/#{account.id}/leaves", + params: valid_params, + headers: administrator.create_new_auth_token + end.to change(Leave, :count).by(1) + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['leave_type']).to eq('annual') + expect(body['user']['id']).to eq(administrator.id) + end + + it 'creates leave with other type' do + params = valid_params.merge(leave: valid_params[:leave].merge(leave_type: 'other')) + + post "/api/v1/accounts/#{account.id}/leaves", + params: params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['leave_type']).to eq('other') + end + end + + context 'when authenticated as agent' do + it 'creates a leave for current user' do + expect do + post "/api/v1/accounts/#{account.id}/leaves", + params: valid_params, + headers: agent.create_new_auth_token + end.to change(Leave, :count).by(1) + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['user']['id']).to eq(agent.id) + end + end + + context 'with invalid parameters' do + it 'returns validation errors for missing start date' do + invalid_params = valid_params.merge(leave: valid_params[:leave].except(:start_date)) + + post "/api/v1/accounts/#{account.id}/leaves", + params: invalid_params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns validation errors for end date before start date' do + invalid_params = valid_params.merge( + leave: valid_params[:leave].merge( + start_date: 2.weeks.from_now.to_date, + end_date: 1.week.from_now.to_date + ) + ) + + post "/api/v1/accounts/#{account.id}/leaves", + params: invalid_params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns validation errors for past start date on pending leave' do + invalid_params = valid_params.merge( + leave: valid_params[:leave].merge( + start_date: 1.week.ago.to_date, + end_date: Date.current + ) + ) + + post "/api/v1/accounts/#{account.id}/leaves", + params: invalid_params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/leaves", + params: valid_params + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT #update' do + let(:leave) { create(:leave, account: account, user: agent, status: :pending) } + let(:other_leave) { create(:leave, account: account, user: other_user, status: :pending) } + let(:approved_leave) { create(:leave, account: account, user: agent, status: :approved) } + + let(:update_params) do + { + leave: { + reason: 'Updated reason' + } + } + end + + context 'when authenticated as administrator' do + it 'updates any pending leave' do + put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}", + params: update_params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['reason']).to eq('Updated reason') + end + + it 'cannot update non-pending leaves' do + put "/api/v1/accounts/#{account.id}/leaves/#{approved_leave.id}", + params: update_params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'updates own pending leave' do + put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}", + params: update_params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['reason']).to eq('Updated reason') + end + + it 'cannot update other user leave' do + put "/api/v1/accounts/#{account.id}/leaves/#{other_leave.id}", + params: update_params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + + it 'cannot update non-pending leaves' do + put "/api/v1/accounts/#{account.id}/leaves/#{approved_leave.id}", + params: update_params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when leave does not exist' do + it 'returns not found' do + put "/api/v1/accounts/#{account.id}/leaves/99999", + params: update_params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}", + params: update_params + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE #destroy' do + let(:pending_leave) { create(:leave, account: account, user: agent, status: :pending) } + let(:approved_future_leave) do + create(:leave, account: account, user: agent, status: :approved, + start_date: 1.month.from_now.to_date, end_date: 1.month.from_now.to_date + 5.days) + end + let(:approved_past_leave) do + create(:leave, account: account, user: agent, status: :approved, + start_date: 1.week.ago.to_date, end_date: 3.days.ago.to_date) + end + let(:other_leave) { create(:leave, account: account, user: other_user, status: :pending) } + + context 'when authenticated as administrator' do + it 'deletes cancellable leaves' do + expect do + delete "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}", + headers: administrator.create_new_auth_token + end.to change(Leave, :count).by(-1) + + expect(response).to have_http_status(:success) + end + + it 'deletes approved future leaves' do + expect do + delete "/api/v1/accounts/#{account.id}/leaves/#{approved_future_leave.id}", + headers: administrator.create_new_auth_token + end.to change(Leave, :count).by(-1) + + expect(response).to have_http_status(:success) + end + + it 'cannot delete non-cancellable leaves' do + delete "/api/v1/accounts/#{account.id}/leaves/#{approved_past_leave.id}", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'deletes own cancellable leaves' do + expect do + delete "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}", + headers: agent.create_new_auth_token + end.to change(Leave, :count).by(-1) + + expect(response).to have_http_status(:success) + end + + it 'cannot delete other user leaves' do + delete "/api/v1/accounts/#{account.id}/leaves/#{other_leave.id}", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when leave does not exist' do + it 'returns not found' do + delete "/api/v1/accounts/#{account.id}/leaves/99999", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PATCH #approve' do + let(:pending_leave) { create(:leave, account: account, user: agent, status: :pending) } + let(:approved_leave) { create(:leave, account: account, user: agent, status: :approved) } + + context 'when authenticated as administrator' do + it 'approves pending leave' do + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/approve", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['status']).to eq('approved') + expect(body['approver']['id']).to eq(administrator.id) + expect(body['approved_at']).not_to be_nil + + pending_leave.reload + expect(pending_leave.status).to eq('approved') + expect(pending_leave.approver).to eq(administrator) + end + + it 'updates approved_at timestamp' do + freeze_time = Time.current + allow(Time).to receive(:current).and_return(freeze_time) + + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/approve", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + pending_leave.reload + expect(pending_leave.approved_at.to_i).to eq(freeze_time.to_i) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/approve", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when leave does not exist' do + it 'returns not found' do + patch "/api/v1/accounts/#{account.id}/leaves/99999/approve", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/approve" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PATCH #reject' do + let(:pending_leave) { create(:leave, account: account, user: agent, status: :pending) } + let(:rejected_leave) { create(:leave, account: account, user: agent, status: :rejected) } + + context 'when authenticated as administrator' do + it 'rejects pending leave' do + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/reject", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['status']).to eq('rejected') + expect(body['approver']['id']).to eq(administrator.id) + expect(body['approved_at']).not_to be_nil + + pending_leave.reload + expect(pending_leave.status).to eq('rejected') + expect(pending_leave.approver).to eq(administrator) + end + + it 'updates approved_at timestamp on rejection' do + freeze_time = Time.current + allow(Time).to receive(:current).and_return(freeze_time) + + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/reject", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + pending_leave.reload + expect(pending_leave.approved_at.to_i).to eq(freeze_time.to_i) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/reject", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when leave does not exist' do + it 'returns not found' do + patch "/api/v1/accounts/#{account.id}/leaves/99999/reject", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + + context 'when unauthenticated' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/leaves/#{pending_leave.id}/reject" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'Business logic scenarios' do + describe 'leave overlap detection' do + let(:existing_leave) do + create(:leave, account: account, user: agent, status: :approved, + start_date: 1.month.from_now.to_date, + end_date: 1.month.from_now.to_date + 5.days) + end + + it 'allows non-overlapping leaves' do + existing_leave + + params = { + leave: { + start_date: 2.months.from_now.to_date, + end_date: 2.months.from_now.to_date + 3.days, + leave_type: 'annual', + reason: 'Second vacation' + } + } + + post "/api/v1/accounts/#{account.id}/leaves", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + end + end + + describe 'leave duration calculation' do + it 'includes duration in response' do + params = { + leave: { + start_date: 1.week.from_now.to_date, + end_date: 1.week.from_now.to_date + 4.days, + leave_type: 'annual', + reason: 'Five day vacation' + } + } + + post "/api/v1/accounts/#{account.id}/leaves", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + expect(body['duration_in_days']).to eq(5) + end + end + + describe 'approver validation' do + let(:non_admin_approver) { create(:user, account: account, role: :agent) } + + before do + # Ensure approver is linked to account + create(:account_user, account: account, user: non_admin_approver, role: :agent) + create(:account_user, account: account, user: administrator, role: :administrator) + end + + it 'rejects leave approval by non-administrator' do + leave = create(:leave, account: account, user: agent, status: :pending) + + # Simulate manual update with non-admin approver (would fail validation) + expect do + leave.update!(approved_by_id: non_admin_approver.id, status: :approved) + end.to raise_error(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/spec/enterprise/models/leave_spec.rb b/spec/enterprise/models/leave_spec.rb new file mode 100644 index 000000000..9793a9c2e --- /dev/null +++ b/spec/enterprise/models/leave_spec.rb @@ -0,0 +1,288 @@ +require 'rails_helper' + +RSpec.describe Leave, type: :model do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:approver) { create(:user, account: account, role: :administrator) } + + describe '#duration_in_days' do + it 'calculates duration correctly for single day' do + leave = create(:leave, start_date: Date.current, end_date: Date.current) + expect(leave.duration_in_days).to eq(1) + end + + it 'calculates duration correctly for multiple days' do + leave = create(:leave, start_date: Date.current, end_date: Date.current + 4.days) + expect(leave.duration_in_days).to eq(5) + end + + it 'returns 0 when start_date is nil' do + leave = build(:leave, start_date: nil, end_date: Date.current) + expect(leave.duration_in_days).to eq(0) + end + + it 'returns 0 when end_date is nil' do + leave = build(:leave, start_date: Date.current, end_date: nil) + expect(leave.duration_in_days).to eq(0) + end + end + + describe '#can_be_cancelled?' do + context 'when leave is pending' do + it 'returns true' do + leave = create(:leave, status: :pending) + expect(leave.can_be_cancelled?).to be true + end + end + + context 'when leave is approved' do + it 'returns true for future leaves' do + leave = create(:leave, status: :approved, + start_date: 1.week.from_now.to_date, + end_date: 2.weeks.from_now.to_date) + expect(leave.can_be_cancelled?).to be true + end + + it 'returns false for current leaves' do + leave = create(:leave, status: :approved, + start_date: Date.current, + end_date: Date.current + 1.day) + expect(leave.can_be_cancelled?).to be false + end + + it 'returns false for past leaves' do + leave = create(:leave, status: :approved, + start_date: 1.week.ago.to_date, + end_date: 3.days.ago.to_date) + expect(leave.can_be_cancelled?).to be false + end + end + + context 'when leave is rejected' do + it 'returns false' do + leave = create(:leave, status: :rejected) + expect(leave.can_be_cancelled?).to be false + end + end + + context 'when leave is cancelled' do + it 'returns false' do + leave = create(:leave, status: :cancelled) + expect(leave.can_be_cancelled?).to be false + end + end + end + + describe '#overlaps_with?' do + let(:base_leave) do + create(:leave, start_date: Date.current + 10.days, + end_date: Date.current + 15.days) + end + + it 'returns false for non-Leave objects' do + expect(base_leave.overlaps_with?('not a leave')).to be false + end + + it 'detects overlapping leaves - same dates' do + overlapping_leave = build(:leave, start_date: Date.current + 10.days, + end_date: Date.current + 15.days) + expect(base_leave.overlaps_with?(overlapping_leave)).to be true + end + + it 'detects overlapping leaves - partial overlap start' do + overlapping_leave = build(:leave, start_date: Date.current + 8.days, + end_date: Date.current + 12.days) + expect(base_leave.overlaps_with?(overlapping_leave)).to be true + end + + it 'detects overlapping leaves - partial overlap end' do + overlapping_leave = build(:leave, start_date: Date.current + 13.days, + end_date: Date.current + 18.days) + expect(base_leave.overlaps_with?(overlapping_leave)).to be true + end + + it 'detects overlapping leaves - contained within' do + overlapping_leave = build(:leave, start_date: Date.current + 12.days, + end_date: Date.current + 13.days) + expect(base_leave.overlaps_with?(overlapping_leave)).to be true + end + + it 'detects overlapping leaves - contains other' do + overlapping_leave = build(:leave, start_date: Date.current + 8.days, + end_date: Date.current + 18.days) + expect(base_leave.overlaps_with?(overlapping_leave)).to be true + end + + it 'returns false for non-overlapping leaves - before' do + non_overlapping_leave = build(:leave, start_date: Date.current + 5.days, + end_date: Date.current + 9.days) + expect(base_leave.overlaps_with?(non_overlapping_leave)).to be false + end + + it 'returns false for non-overlapping leaves - after' do + non_overlapping_leave = build(:leave, start_date: Date.current + 16.days, + end_date: Date.current + 20.days) + expect(base_leave.overlaps_with?(non_overlapping_leave)).to be false + end + + it 'returns false for adjacent leaves - ending where other starts' do + adjacent_leave = build(:leave, start_date: Date.current + 16.days, + end_date: Date.current + 20.days) + expect(base_leave.overlaps_with?(adjacent_leave)).to be false + end + + it 'returns false for adjacent leaves - starting where other ends' do + adjacent_leave = build(:leave, start_date: Date.current + 5.days, + end_date: Date.current + 9.days) + expect(base_leave.overlaps_with?(adjacent_leave)).to be false + end + end + + describe '#approve!' do + let(:leave) { create(:leave, status: :pending) } + + before do + create(:account_user, account: account, user: approver, role: :administrator) + end + + it 'updates status to approved' do + leave.approve!(approver) + expect(leave.status).to eq('approved') + end + + it 'sets approver' do + leave.approve!(approver) + expect(leave.approver).to eq(approver) + end + + it 'sets approved_at timestamp' do + freeze_time = Time.current + allow(Time).to receive(:current).and_return(freeze_time) + + leave.approve!(approver) + expect(leave.approved_at.to_i).to eq(freeze_time.to_i) + end + + it 'raises error if approver is not admin' do + non_admin = create(:user, account: account, role: :agent) + create(:account_user, account: account, user: non_admin, role: :agent) + + expect do + leave.approve!(non_admin) + end.to raise_error(ActiveRecord::RecordInvalid, /must be an administrator/) + end + end + + describe '#reject!' do + let(:leave) { create(:leave, status: :pending) } + + before do + create(:account_user, account: account, user: approver, role: :administrator) + end + + it 'updates status to rejected' do + leave.reject!(approver) + expect(leave.status).to eq('rejected') + end + + it 'sets approver' do + leave.reject!(approver) + expect(leave.approver).to eq(approver) + end + + it 'sets approved_at timestamp' do + freeze_time = Time.current + allow(Time).to receive(:current).and_return(freeze_time) + + leave.reject!(approver) + expect(leave.approved_at.to_i).to eq(freeze_time.to_i) + end + + it 'raises error if approver is not admin' do + non_admin = create(:user, account: account, role: :agent) + create(:account_user, account: account, user: non_admin, role: :agent) + + expect do + leave.reject!(non_admin) + end.to raise_error(ActiveRecord::RecordInvalid, /must be an administrator/) + end + end + + describe 'custom validations' do + describe '#end_date_after_start_date' do + it 'is valid when end_date is after start_date' do + leave = build(:leave, start_date: Date.current, end_date: Date.current + 1.day) + expect(leave).to be_valid + end + + it 'is valid when end_date equals start_date' do + leave = build(:leave, start_date: Date.current, end_date: Date.current) + expect(leave).to be_valid + end + + it 'is invalid when end_date is before start_date' do + leave = build(:leave, start_date: Date.current + 1.day, end_date: Date.current) + expect(leave).not_to be_valid + expect(leave.errors[:end_date]).to include('must be after start date') + end + + it 'skips validation when dates are nil' do + leave = build(:leave, start_date: nil, end_date: nil) + leave.valid? + expect(leave.errors[:end_date]).not_to include('must be after start date') + end + end + + describe '#future_dates_for_pending_leaves' do + it 'is valid for future start_date on pending leave' do + leave = build(:leave, status: :pending, start_date: Date.current + 1.day) + expect(leave).to be_valid + end + + it 'is invalid for current start_date on pending leave' do + leave = build(:leave, status: :pending, start_date: Date.current) + expect(leave).not_to be_valid + expect(leave.errors[:start_date]).to include('must be in the future') + end + + it 'is invalid for past start_date on pending leave' do + leave = build(:leave, status: :pending, start_date: Date.current - 1.day) + expect(leave).not_to be_valid + expect(leave.errors[:start_date]).to include('must be in the future') + end + + it 'skips validation for non-pending leaves' do + leave = build(:leave, status: :approved, start_date: Date.current - 1.day) + leave.valid? + expect(leave.errors[:start_date]).not_to include('must be in the future') + end + end + + describe '#approver_is_admin' do + let(:admin_user) { create(:user, account: account, role: :administrator) } + let(:agent_user) { create(:user, account: account, role: :agent) } + + before do + create(:account_user, account: account, user: admin_user, role: :administrator) + create(:account_user, account: account, user: agent_user, role: :agent) + end + + it 'is valid with admin approver' do + leave = build(:leave, account: account, approver: admin_user, status: :approved) + expect(leave).to be_valid + end + + it 'is invalid with non-admin approver' do + leave = build(:leave, account: account, approver: agent_user, status: :approved) + expect(leave).not_to be_valid + expect(leave.errors[:approved_by]).to include('must be an administrator') + end + + it 'skips validation when approver is nil' do + leave = build(:leave, account: account, approver: nil) + leave.valid? + expect(leave.errors[:approved_by]).not_to include('must be an administrator') + end + end + end +end diff --git a/spec/enterprise/policies/leave_policy_spec.rb b/spec/enterprise/policies/leave_policy_spec.rb new file mode 100644 index 000000000..6fee746ab --- /dev/null +++ b/spec/enterprise/policies/leave_policy_spec.rb @@ -0,0 +1,250 @@ +require 'rails_helper' + +RSpec.describe LeavePolicy, type: :policy do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:other_agent) { create(:user, account: account, role: :agent) } + + let(:admin_account_user) { create(:account_user, account: account, user: administrator, role: :administrator) } + let(:agent_account_user) { create(:account_user, account: account, user: agent, role: :agent) } + let(:other_agent_account_user) { create(:account_user, account: account, user: other_agent, role: :agent) } + + let(:admin_context) { { user: administrator, account: account, account_user: admin_account_user } } + let(:agent_context) { { user: agent, account: account, account_user: agent_account_user } } + let(:other_agent_context) { { user: other_agent, account: account, account_user: other_agent_account_user } } + + let(:agent_leave) { create(:leave, account: account, user: agent, status: :pending) } + let(:other_agent_leave) { create(:leave, account: account, user: other_agent, status: :pending) } + + describe 'update?' do + context 'when leave is pending' do + context 'when user is administrator' do + it 'allows update of any leave' do + policy = LeavePolicy.new(admin_context, agent_leave) + expect(policy.update?).to be true + end + end + + context 'when user owns the leave' do + it 'allows update' do + policy = LeavePolicy.new(agent_context, agent_leave) + expect(policy.update?).to be true + end + end + + context 'when user does not own the leave' do + it 'denies update' do + policy = LeavePolicy.new(other_agent_context, agent_leave) + expect(policy.update?).to be false + end + end + end + + context 'when leave is not pending' do + let(:approved_leave) { create(:leave, account: account, user: agent, status: :approved) } + let(:rejected_leave) { create(:leave, account: account, user: agent, status: :rejected) } + let(:cancelled_leave) { create(:leave, account: account, user: agent, status: :cancelled) } + + context 'when user is administrator' do + it 'denies update of approved leave' do + policy = LeavePolicy.new(admin_context, approved_leave) + expect(policy.update?).to be false + end + + it 'denies update of rejected leave' do + policy = LeavePolicy.new(admin_context, rejected_leave) + expect(policy.update?).to be false + end + + it 'denies update of cancelled leave' do + policy = LeavePolicy.new(admin_context, cancelled_leave) + expect(policy.update?).to be false + end + end + + context 'when user owns the leave' do + it 'denies update of approved leave' do + policy = LeavePolicy.new(agent_context, approved_leave) + expect(policy.update?).to be false + end + + it 'denies update of rejected leave' do + policy = LeavePolicy.new(agent_context, rejected_leave) + expect(policy.update?).to be false + end + + it 'denies update of cancelled leave' do + policy = LeavePolicy.new(agent_context, cancelled_leave) + expect(policy.update?).to be false + end + end + end + end + + describe 'destroy?' do + context 'when leave can be cancelled' do + let(:pending_leave) { create(:leave, account: account, user: agent, status: :pending) } + let(:approved_future_leave) do + create(:leave, account: account, user: agent, status: :approved, + start_date: 1.week.from_now.to_date, end_date: 2.weeks.from_now.to_date) + end + + context 'when user is administrator' do + it 'allows destroy of pending leave' do + policy = LeavePolicy.new(admin_context, pending_leave) + expect(policy.destroy?).to be true + end + + it 'allows destroy of approved future leave' do + policy = LeavePolicy.new(admin_context, approved_future_leave) + expect(policy.destroy?).to be true + end + + it 'allows destroy of other user leaves' do + other_leave = create(:leave, account: account, user: other_agent, status: :pending) + policy = LeavePolicy.new(admin_context, other_leave) + expect(policy.destroy?).to be true + end + end + + context 'when user owns the leave' do + it 'allows destroy of own pending leave' do + policy = LeavePolicy.new(agent_context, pending_leave) + expect(policy.destroy?).to be true + end + + it 'allows destroy of own approved future leave' do + policy = LeavePolicy.new(agent_context, approved_future_leave) + expect(policy.destroy?).to be true + end + end + + context 'when user does not own the leave' do + it 'denies destroy of other user leave' do + policy = LeavePolicy.new(other_agent_context, pending_leave) + expect(policy.destroy?).to be false + end + end + end + + context 'when leave cannot be cancelled' do + let(:approved_current_leave) do + create(:leave, account: account, user: agent, status: :approved, + start_date: Date.current, end_date: Date.current + 2.days) + end + let(:approved_past_leave) do + create(:leave, account: account, user: agent, status: :approved, + start_date: 1.week.ago.to_date, end_date: 3.days.ago.to_date) + end + let(:rejected_leave) { create(:leave, account: account, user: agent, status: :rejected) } + + context 'when user is administrator' do + it 'denies destroy of current approved leave' do + policy = LeavePolicy.new(admin_context, approved_current_leave) + expect(policy.destroy?).to be false + end + + it 'denies destroy of past approved leave' do + policy = LeavePolicy.new(admin_context, approved_past_leave) + expect(policy.destroy?).to be false + end + + it 'denies destroy of rejected leave' do + policy = LeavePolicy.new(admin_context, rejected_leave) + expect(policy.destroy?).to be false + end + end + + context 'when user owns the leave' do + it 'denies destroy of current approved leave' do + policy = LeavePolicy.new(agent_context, approved_current_leave) + expect(policy.destroy?).to be false + end + + it 'denies destroy of past approved leave' do + policy = LeavePolicy.new(agent_context, approved_past_leave) + expect(policy.destroy?).to be false + end + + it 'denies destroy of rejected leave' do + policy = LeavePolicy.new(agent_context, rejected_leave) + expect(policy.destroy?).to be false + end + end + end + end + + describe 'Scope' do + let(:admin_leave) { create(:leave, account: account, user: administrator) } + let(:agent_leave) { create(:leave, account: account, user: agent) } + let(:other_agent_leave) { create(:leave, account: account, user: other_agent) } + + before do + admin_leave + agent_leave + other_agent_leave + end + + context 'when user is administrator' do + it 'returns all leaves in account with includes' do + scope = LeavePolicy::Scope.new(admin_context, Leave.all) + result = scope.resolve + + expect(result).to include(admin_leave, agent_leave, other_agent_leave) + expect(result.includes_values).to include(:user, :approver) + end + end + + context 'when user is not administrator' do + it 'returns only own leaves with includes' do + scope = LeavePolicy::Scope.new(agent_context, Leave.all) + result = scope.resolve + + expect(result).to include(agent_leave) + expect(result).not_to include(admin_leave, other_agent_leave) + expect(result.includes_values).to include(:user, :approver) + end + + it 'filters correctly for other agent' do + scope = LeavePolicy::Scope.new(other_agent_context, Leave.all) + result = scope.resolve + + expect(result).to include(other_agent_leave) + expect(result).not_to include(admin_leave, agent_leave) + end + end + + context 'scope initialization' do + it 'properly initializes all context variables' do + scope = LeavePolicy::Scope.new(admin_context, Leave.all) + + expect(scope.user_context).to eq(admin_context) + expect(scope.user).to eq(administrator) + expect(scope.account).to eq(account) + expect(scope.account_user).to eq(admin_account_user) + expect(scope.scope).to eq(Leave.all) + end + end + end + + describe 'ownership checks' do + let(:policy) { LeavePolicy.new(agent_context, agent_leave) } + + describe '#owned_by_user?' do + it 'returns true when user owns the leave' do + expect(policy.send(:owned_by_user?)).to be true + end + + it 'returns false when user does not own the leave' do + policy = LeavePolicy.new(other_agent_context, agent_leave) + expect(policy.send(:owned_by_user?)).to be false + end + + it 'returns false when record is nil' do + policy = LeavePolicy.new(agent_context, nil) + expect(policy.send(:owned_by_user?)).to be false + end + end + end +end diff --git a/spec/factories/leaves.rb b/spec/factories/leaves.rb new file mode 100644 index 000000000..7b63873b8 --- /dev/null +++ b/spec/factories/leaves.rb @@ -0,0 +1,43 @@ +FactoryBot.define do + factory :leave do + account + user + start_date { 1.week.from_now.to_date } + end_date { 2.weeks.from_now.to_date } + leave_type { :annual } + status { :pending } + reason { 'Annual vacation leave' } + + trait :sick do + leave_type { :sick } + reason { 'Sick leave for medical treatment' } + end + + trait :emergency do + leave_type { :emergency } + reason { 'Emergency family matter' } + end + + trait :other do + leave_type { :other } + reason { 'Other type of leave' } + end + + trait :approved do + status { :approved } + approved_by { association(:user) } + approved_at { 1.day.ago } + end + + trait :rejected do + status { :rejected } + approved_by { association(:user) } + approved_at { 1.day.ago } + end + + trait :past_dates do + start_date { 2.weeks.ago.to_date } + end_date { 1.week.ago.to_date } + end + end +end