From ba5069a18bd32db1ed376d6b4acb994f9798c278 Mon Sep 17 00:00:00 2001 From: Jon Roberts Date: Tue, 24 Sep 2024 21:41:37 -0400 Subject: [PATCH] additional expenses and contact topic answer controllers --- .../additional_expenses_controller.rb | 32 +++++ .../contact_topic_answers_controller.rb | 32 +++++ app/models/additional_expense.rb | 12 +- app/models/contact_topic_answer.rb | 3 + app/policies/additional_expense_policy.rb | 27 +++++ app/policies/contact_topic_answer_policy.rb | 27 +++++ config/locales/en.yml | 3 + config/routes.rb | 6 + spec/models/additional_expense_spec.rb | 13 ++ .../additional_expense_policy_spec.rb | 109 +++++++++++++++++ .../contact_topic_answer_policy_spec.rb | 111 ++++++++++++++++++ spec/requests/additional_expenses_spec.rb | 85 ++++++++++++++ spec/requests/contact_topic_answers_spec.rb | 86 ++++++++++++++ 13 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 app/controllers/additional_expenses_controller.rb create mode 100644 app/controllers/contact_topic_answers_controller.rb create mode 100644 app/policies/additional_expense_policy.rb create mode 100644 app/policies/contact_topic_answer_policy.rb create mode 100644 spec/policies/additional_expense_policy_spec.rb create mode 100644 spec/policies/contact_topic_answer_policy_spec.rb create mode 100644 spec/requests/additional_expenses_spec.rb create mode 100644 spec/requests/contact_topic_answers_spec.rb diff --git a/app/controllers/additional_expenses_controller.rb b/app/controllers/additional_expenses_controller.rb new file mode 100644 index 0000000000..02e9200e4d --- /dev/null +++ b/app/controllers/additional_expenses_controller.rb @@ -0,0 +1,32 @@ +class AdditionalExpensesController < ApplicationController + def create + @additional_expense = AdditionalExpense.new(additional_expense_params) + authorize @additional_expense + + respond_to do |format| + if @additional_expense.save + format.json { render json: @additional_expense.as_json, status: :created } + else + format.json { render json: @additional_expense.errors.as_json, status: :unprocessable_entity } + end + end + end + + def destroy + @additional_expense = AdditionalExpense.find(params[:id]) + authorize @additional_expense + + @additional_expense.destroy! + + respond_to do |format| + format.json { head :no_content } + end + end + + private + + def additional_expense_params + params.require(:additional_expense) + .permit(:case_contact_id, :other_expense_amount, :other_expenses_describe) + end +end diff --git a/app/controllers/contact_topic_answers_controller.rb b/app/controllers/contact_topic_answers_controller.rb new file mode 100644 index 0000000000..85d412cb49 --- /dev/null +++ b/app/controllers/contact_topic_answers_controller.rb @@ -0,0 +1,32 @@ +class ContactTopicAnswersController < ApplicationController + def create + @contact_topic_answer = ContactTopicAnswer.new(contact_topic_answer_params) + authorize @contact_topic_answer + + respond_to do |format| + if @contact_topic_answer.save + format.json { render json: @contact_topic_answer.as_json, status: :created } + else + format.json { render json: @contact_topic_answer.errors.as_json, status: :unprocessable_entity } + end + end + end + + def destroy + @contact_topic_answer = ContactTopicAnswer.find(params[:id]) + authorize @contact_topic_answer + + @contact_topic_answer.destroy! + + respond_to do |format| + format.json { head :no_content } + end + end + + private + + def contact_topic_answer_params + params.require(:contact_topic_answer) + .permit(:id, :contact_topic_id, :case_contact_id, :value, :_destroy) + end +end diff --git a/app/models/additional_expense.rb b/app/models/additional_expense.rb index 12856b8ce2..6ed7f2567e 100644 --- a/app/models/additional_expense.rb +++ b/app/models/additional_expense.rb @@ -1,8 +1,16 @@ class AdditionalExpense < ApplicationRecord belongs_to :case_contact + has_one :casa_case, through: :case_contact + has_one :casa_org, through: :casa_case - # validates :other_expense_amount, presence: true - validates :other_expenses_describe, presence: {message: "Expense description cannot be blank."} + validates :other_expenses_describe, presence: true, if: :describe_required? + + alias_attribute :amount, :other_expense_amount + alias_attribute :describe, :other_expenses_describe + + def describe_required? + other_expense_amount&.positive? + end end # == Schema Information diff --git a/app/models/contact_topic_answer.rb b/app/models/contact_topic_answer.rb index 918d2f1bab..2a1928040e 100644 --- a/app/models/contact_topic_answer.rb +++ b/app/models/contact_topic_answer.rb @@ -2,6 +2,9 @@ class ContactTopicAnswer < ApplicationRecord belongs_to :case_contact belongs_to :contact_topic + has_one :casa_case, through: :case_contact + has_one :casa_org, through: :casa_case + validates :selected, inclusion: [true, false] default_scope { joins(:contact_topic).order("contact_topics.id") } diff --git a/app/policies/additional_expense_policy.rb b/app/policies/additional_expense_policy.rb new file mode 100644 index 0000000000..921fa088c9 --- /dev/null +++ b/app/policies/additional_expense_policy.rb @@ -0,0 +1,27 @@ +class AdditionalExpensePolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + case user + when CasaAdmin, Supervisor + scope.joins([:case_contact, :casa_case]).where(casa_case: {casa_org_id: user.casa_org.id}) + when Volunteer + scope.where(case_contact: user.case_contacts) + else + scope.none + end + end + end + + def create? + case user + when Volunteer + user.case_contacts.include?(record.case_contact) + when CasaAdmin, Supervisor + same_org? + else + false + end + end + + alias_method :destroy?, :create? +end diff --git a/app/policies/contact_topic_answer_policy.rb b/app/policies/contact_topic_answer_policy.rb new file mode 100644 index 0000000000..1c0ae4f444 --- /dev/null +++ b/app/policies/contact_topic_answer_policy.rb @@ -0,0 +1,27 @@ +class ContactTopicAnswerPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + case user + when CasaAdmin, Supervisor + scope.joins([:case_contact, :casa_case]).where(casa_case: {casa_org_id: user.casa_org&.id}) + when Volunteer + scope.where(case_contact: user.case_contacts) + else + scope.none + end + end + end + + def create? + case user + when Volunteer + user.case_contacts.include?(record.case_contact) + when CasaAdmin, Supervisor + same_org? + else + false + end + end + + alias_method :destroy?, :create? +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e42aaf7a0..904542506f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -32,6 +32,9 @@ en: activerecord: attributes: + additional_expense: + other_expenses_amount: Amount + other_expenses_describe: Description case_contact: case_contact_contact_types: one: Contact Type diff --git a/config/routes.rb b/config/routes.rb index 95b62d5c59..51141f9f7e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,9 @@ end end + resources :contact_topic_answers, only: %i[create destroy], + constraints: lambda { |req| req.format == :json } + resources :reports, only: %i[index] get :export_emails, to: "reports#export_emails" @@ -191,6 +194,9 @@ end resources :case_court_orders, only: %i[destroy] + resources :additional_expenses, only: %i[create destroy], + constraints: lambda { |req| req.format == :json } + namespace :all_casa_admins do resources :casa_orgs, only: [:new, :create, :show] do resources :casa_admins, only: [:new, :create, :edit, :update] do diff --git a/spec/models/additional_expense_spec.rb b/spec/models/additional_expense_spec.rb index 83155512c1..a3e45a48e2 100644 --- a/spec/models/additional_expense_spec.rb +++ b/spec/models/additional_expense_spec.rb @@ -2,4 +2,17 @@ RSpec.describe AdditionalExpense, type: :model do it { is_expected.to belong_to(:case_contact) } + it { is_expected.to have_one(:casa_case).through(:case_contact) } + it { is_expected.to have_one(:casa_org).through(:casa_case) } + + describe "validations" do + let(:case_contact) { build_stubbed :case_contact } + + it "requires describe only if amount is positive" do + expense = build(:additional_expense, amount: 0, describe: nil, case_contact:) + expect(expense).to be_valid + expense.update(amount: 1) + expect(expense).to be_invalid + end + end end diff --git a/spec/policies/additional_expense_policy_spec.rb b/spec/policies/additional_expense_policy_spec.rb new file mode 100644 index 0000000000..d6f1d817ca --- /dev/null +++ b/spec/policies/additional_expense_policy_spec.rb @@ -0,0 +1,109 @@ +require "rails_helper" + +RSpec.describe AdditionalExpensePolicy, type: :policy do + let(:casa_org) { create :casa_org } + let(:volunteer) { create :volunteer, :with_single_case, casa_org: } + let(:supervisor) { create :supervisor, casa_org: } + let(:casa_admin) { create :casa_admin, casa_org: } + let(:all_casa_admin) { create :all_casa_admin } + + let(:casa_case) { volunteer.casa_cases.first } + let(:case_contact) { create :case_contact, casa_case:, creator: volunteer } + let!(:additional_expense) { create :additional_expense, case_contact: } + + let(:same_org_volunteer) { create :volunteer, casa_org: } + let!(:same_org_volunteer_case_assignment) { create :case_assignment, volunteer: same_org_volunteer, casa_case: } + + subject { described_class } + + permissions :create?, :destroy? do + it "does not permit a nil user" do + expect(described_class).not_to permit(nil, additional_expense) + end + + it "permits a volunteer assigned to the expense's case contact" do + expect(described_class).to permit(volunteer, additional_expense) + end + + it "does not permit a volunteer who did not create the case contact" do + expect(same_org_volunteer.casa_cases).to include(casa_case) + expect(described_class).not_to permit(same_org_volunteer, additional_expense) + end + + it "permits a supervisor" do + expect(described_class).to permit(supervisor, additional_expense) + end + + it "does not permit a supervisor for a different casa org" do + other_org_supervisor = create :supervisor, casa_org: create(:casa_org) + expect(described_class).not_to permit(other_org_supervisor, additional_expense) + end + + it "permits a casa admin" do + expect(described_class).to permit(casa_admin, additional_expense) + end + + it "does not permit a casa admin for a different casa org" do + other_org_casa_admin = create :casa_admin, casa_org: create(:casa_org) + expect(described_class).not_to permit(other_org_casa_admin, additional_expense) + end + + it "does not permit an all casa admin" do + expect(described_class).not_to permit(all_casa_admin, additional_expense) + end + end + + describe "Scope#resolve" do + let(:same_org_volunteer_case_contact) { create :case_contact, casa_case:, creator: same_org_volunteer } + let!(:same_org_other_volunteer_additional_expense) do + create :additional_expense, case_contact: same_org_volunteer_case_contact + end + + let(:other_volunteer_case_contact) { create :case_contact, casa_case:, creator: other_volunteer } + let!(:other_volunteer_additional_expense) { create :additional_expense, case_contact: other_org_case_contact } + + let(:other_org) { create :casa_org } + let(:other_org_volunteer) { create :volunteer, casa_org: other_org } + let(:other_org_casa_case) { create :casa_case, casa_org: other_org } + let(:other_org_case_contact) { create :case_contact, casa_case: other_org_casa_case, creator: other_org_volunteer } + let!(:other_org_additional_expense) { create :additional_expense, case_contact: other_org_case_contact } + + subject { described_class::Scope.new(user, AdditionalExpense.all).resolve } + + context "when user is a visitor" do + let(:user) { nil } + + it { is_expected.not_to include(additional_expense) } + it { is_expected.not_to include(other_org_additional_expense) } + end + + context "when user is a volunteer" do + let(:user) { volunteer } + + it { is_expected.to include(additional_expense) } + it { is_expected.not_to include(other_volunteer_additional_expense) } + it { is_expected.not_to include(other_org_additional_expense) } + end + + context "when user is a supervisor" do + let(:user) { supervisor } + + it { is_expected.to include(additional_expense) } + it { is_expected.not_to include(other_org_additional_expense) } + end + + context "when user is a casa_admin" do + let(:user) { casa_admin } + + it { is_expected.to include(additional_expense) } + it { is_expected.not_to include(other_org_additional_expense) } + end + + context "when user is an all_casa_admin" do + let(:user) { all_casa_admin } + + it { is_expected.not_to include(additional_expense) } + it { is_expected.not_to include(other_org_additional_expense) } + end + end +end diff --git a/spec/policies/contact_topic_answer_policy_spec.rb b/spec/policies/contact_topic_answer_policy_spec.rb new file mode 100644 index 0000000000..7cd35d08ed --- /dev/null +++ b/spec/policies/contact_topic_answer_policy_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +RSpec.describe ContactTopicAnswerPolicy, type: :policy do + let(:casa_org) { create :casa_org } + let(:volunteer) { create :volunteer, :with_single_case, casa_org: } + let(:supervisor) { create :supervisor, casa_org: } + let(:casa_admin) { create :casa_admin, casa_org: } + let(:all_casa_admin) { create :all_casa_admin } + + let(:contact_topic) { create :contact_topic, casa_org: } + let(:casa_case) { volunteer.casa_cases.first } + let(:case_contact) { create :case_contact, casa_case:, creator: volunteer } + let!(:contact_topic_answer) { create :contact_topic_answer, contact_topic:, case_contact: } + + let(:same_org_volunteer) { create :volunteer, casa_org: } + let!(:same_org_volunteer_case_assignment) { create :case_assignment, volunteer: same_org_volunteer, casa_case: } + + subject { described_class } + + permissions :create?, :destroy? do + it "does not permit a nil user" do + expect(described_class).not_to permit(nil, contact_topic_answer) + end + + it "permits a volunteer assigned to the contact topic answer case" do + expect(described_class).to permit(volunteer, contact_topic_answer) + end + + it "does not permit a volunteer who did not create the case contact" do + expect(same_org_volunteer.casa_cases).to include(casa_case) + expect(described_class).not_to permit(same_org_volunteer, contact_topic_answer) + end + + it "permits a supervisor" do + expect(described_class).to permit(supervisor, contact_topic_answer) + end + + it "does not permit a supervisor for a different casa org" do + other_org_supervisor = create :supervisor, casa_org: create(:casa_org) + expect(described_class).not_to permit(other_org_supervisor, contact_topic_answer) + end + + it "permits a casa admin" do + expect(described_class).to permit(casa_admin, contact_topic_answer) + end + + it "does not permit a casa admin for a different casa org" do + other_org_casa_admin = create :casa_admin, casa_org: create(:casa_org) + expect(described_class).not_to permit(other_org_casa_admin, contact_topic_answer) + end + + it "does not permit an all casa admin" do + expect(described_class).not_to permit(all_casa_admin, contact_topic_answer) + end + end + + describe "Scope#resolve" do + let(:same_org_volunteer_case_contact) { create :case_contact, casa_case:, creator: same_org_volunteer } + let!(:same_org_other_volunteer_contact_topic_answer) do + create :contact_topic_answer, contact_topic:, case_contact: same_org_volunteer_case_contact + end + + let(:other_volunteer_case_contact) { create :case_contact, casa_case:, creator: other_volunteer } + let!(:other_volunteer_contact_topic_answer) { create :contact_topic_answer, contact_topic:, case_contact: other_org_case_contact } + + let(:other_org) { create :casa_org } + let(:other_org_volunteer) { create :volunteer, casa_org: other_org } + let(:other_org_contact_topic) { create :contact_topic, casa_org: other_org } + let(:other_org_casa_case) { create :casa_case, casa_org: other_org } + let(:other_org_case_contact) { create :case_contact, casa_case: other_org_casa_case, creator: other_org_volunteer } + let!(:other_org_contact_topic_answer) { create :contact_topic_answer, case_contact: other_org_case_contact, contact_topic: other_org_contact_topic } + + subject { described_class::Scope.new(user, ContactTopicAnswer.all).resolve } + + context "when user is a visitor" do + let(:user) { nil } + + it { is_expected.not_to include(contact_topic_answer) } + it { is_expected.not_to include(other_org_contact_topic_answer) } + end + + context "when user is a volunteer" do + let(:user) { volunteer } + + it { is_expected.to include(contact_topic_answer) } + it { is_expected.not_to include(other_volunteer_contact_topic_answer) } + it { is_expected.not_to include(other_org_contact_topic_answer) } + end + + context "when user is a supervisor" do + let(:user) { supervisor } + + it { is_expected.to include(contact_topic_answer) } + it { is_expected.not_to include(other_org_contact_topic_answer) } + end + + context "when user is a casa_admin" do + let(:user) { casa_admin } + + it { is_expected.to include(contact_topic_answer) } + it { is_expected.not_to include(other_org_contact_topic_answer) } + end + + context "when user is an all_casa_admin" do + let(:user) { all_casa_admin } + + it { is_expected.not_to include(contact_topic_answer) } + it { is_expected.not_to include(other_org_contact_topic_answer) } + end + end +end diff --git a/spec/requests/additional_expenses_spec.rb b/spec/requests/additional_expenses_spec.rb new file mode 100644 index 0000000000..64401f3828 --- /dev/null +++ b/spec/requests/additional_expenses_spec.rb @@ -0,0 +1,85 @@ +require "rails_helper" + +RSpec.describe "/additional_expenses", type: :request do + let(:casa_org) { create :casa_org } + let(:volunteer) { create :volunteer, :with_single_case, casa_org: } + let(:casa_case) { volunteer.casa_cases.first } + let(:case_contact) { create :case_contact, casa_case:, creator: volunteer } + + let(:valid_attributes) do + attributes_for(:additional_expense) + .merge({case_contact_id: case_contact.id}) + end + let(:invalid_attributes) { valid_attributes.merge({other_expenses_describe: nil, other_expense_amount: 1}) } + + before { sign_in volunteer } + + describe "POST /create" do + let(:params) { {additional_expense: valid_attributes} } + + subject { post additional_expenses_path, params:, as: :json } + + it "creates a record and responds created" do + expect { subject }.to change(AdditionalExpense, :count).by(1) + expect(response).to have_http_status(:created) + end + + it "returns the new contact topic answer as json" do + subject + expect(response.content_type).to match(a_string_including("application/json")) + answer = AdditionalExpense.last + response_json = JSON.parse(response.body) + expect(response_json["id"]).to eq answer.id + expect(response_json.keys) + .to include("id", "case_contact_id", "other_expense_amount", "other_expenses_describe") + end + + context "with invalid parameters" do + let(:params) { {additional_expense: invalid_attributes} } + + it "fails and responds unprocessable_entity" do + expect { subject }.to change(ContactTopicAnswer, :count).by(0) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "returns errors as json" do + subject + expect(response.content_type).to match(a_string_including("application/json")) + expect(response.body).to be_present + response_json = JSON.parse(response.body) + expect(response_json["other_expenses_describe"]).to include("can't be blank") + end + end + + context "html request" do + subject { post additional_expenses_path, params: } + + it "raises RoutingError" do + expect { subject }.to raise_error(ActionController::RoutingError) + expect(response).to be_blank + end + end + end + + describe "DELETE /destroy" do + let!(:additional_expense) { create :additional_expense, case_contact: } + + subject { delete additional_expense_url(additional_expense), as: :json } + + it "destroys the record and responds no content" do + expect { subject } + .to change(AdditionalExpense, :count).by(-1) + expect(response).to have_http_status(:no_content) + expect(response.body).to be_empty + end + + context "html request" do + subject { delete additional_expense_url(additional_expense) } + + it "redirects to the contact topic answer" do + expect { subject }.to raise_error(ActionController::RoutingError) + expect(response).to be_blank + end + end + end +end diff --git a/spec/requests/contact_topic_answers_spec.rb b/spec/requests/contact_topic_answers_spec.rb new file mode 100644 index 0000000000..c0e20fac51 --- /dev/null +++ b/spec/requests/contact_topic_answers_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe "/contact_topic_answers", type: :request do + let(:casa_org) { create :casa_org } + let(:contact_topic) { create :contact_topic, casa_org: } + let(:volunteer) { create :volunteer, :with_single_case, casa_org: } + let(:casa_case) { volunteer.casa_cases.first } + let(:case_contact) { create :case_contact, casa_case:, creator: volunteer } + + let(:valid_attributes) do + attributes_for(:contact_topic_answer) + .merge({contact_topic_id: contact_topic.id, case_contact_id: case_contact.id}) + end + let(:invalid_attributes) { valid_attributes.merge({contact_topic_id: nil}) } + + before { sign_in volunteer } + + describe "POST /create" do + let(:params) { {contact_topic_answer: valid_attributes} } + + subject { post contact_topic_answers_path, params:, as: :json } + + it "creates a record and responds created" do + expect { subject }.to change(ContactTopicAnswer, :count).by(1) + expect(response).to have_http_status(:created) + end + + it "returns the record as json" do + subject + expect(response.content_type).to match(a_string_including("application/json")) + answer = ContactTopicAnswer.last + response_json = JSON.parse(response.body) + expect(response_json["id"]).to eq answer.id + expect(response_json.keys) + .to contain_exactly("id", "contact_topic_id", "value", "case_contact_id", "created_at", "updated_at", "selected") + end + + context "with invalid parameters" do + let(:params) { {contact_topic_answer: invalid_attributes} } + + it "fails and responds unprocessable_entity" do + expect { subject }.to change(ContactTopicAnswer, :count).by(0) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "returns errors as json" do + subject + expect(response.content_type).to match(a_string_including("application/json")) + expect(response.body).to be_present + response_json = JSON.parse(response.body) + expect(response_json["contact_topic"]).to include("must exist") + end + end + + context "html request" do + subject { post contact_topic_answers_path, params: } + + it "raises RoutingError" do + expect { subject }.to raise_error(ActionController::RoutingError) + expect(response).to be_blank + end + end + end + + describe "DELETE /destroy" do + let!(:contact_topic_answer) { create :contact_topic_answer, case_contact:, contact_topic: } + + subject { delete contact_topic_answer_url(contact_topic_answer), as: :json } + + it "destroys the record and responds no content" do + expect { subject } + .to change(ContactTopicAnswer, :count).by(-1) + expect(response).to have_http_status(:no_content) + expect(response.body).to be_empty + end + + context "html request" do + subject { delete contact_topic_answer_url(contact_topic_answer) } + + it "raises RoutingError" do + expect { subject }.to raise_error(ActionController::RoutingError) + expect(response).to be_blank + end + end + end +end