From e56155ca257ba3e3e9254ff89d8931a3ad573311 Mon Sep 17 00:00:00 2001 From: Steve Brudz Date: Mon, 16 Dec 2024 16:17:47 -0600 Subject: [PATCH 1/4] Customize simplecov config for phoenix --- .simplecov | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.simplecov b/.simplecov index 1071079540..526a82267f 100644 --- a/.simplecov +++ b/.simplecov @@ -1,3 +1,4 @@ SimpleCov.start 'rails' do - # any custom configs like groups and filters can be here at a central place + enable_coverage :branch + add_filter 'phoenix-tests' end From 744b0e577de00b72a731c86945491bc54cabe4c3 Mon Sep 17 00:00:00 2001 From: Steve Brudz Date: Mon, 16 Dec 2024 17:04:09 -0600 Subject: [PATCH 2/4] Use simplecov-json gem instead of simplecov_json_formatter --- .simplecov | 7 +++++++ Gemfile | 1 + Gemfile.lock | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/.simplecov b/.simplecov index 526a82267f..67b86abf8c 100644 --- a/.simplecov +++ b/.simplecov @@ -1,4 +1,11 @@ +require 'simplecov' +require 'simplecov-json' SimpleCov.start 'rails' do + # any custom configs like groups and filters can be here at a central place enable_coverage :branch + formatter SimpleCov::Formatter::MultiFormatter[ + SimpleCov::Formatter::JSONFormatter, + SimpleCov::Formatter::HTMLFormatter + ] add_filter 'phoenix-tests' end diff --git a/Gemfile b/Gemfile index 1744d1218e..1603709196 100644 --- a/Gemfile +++ b/Gemfile @@ -196,6 +196,7 @@ group :test do gem "rails-controller-testing" # Show code coverage. gem 'simplecov' + gem 'simplecov-json' # More concise test ("should") matchers gem 'shoulda-matchers', '~> 6.2' # Mock HTTP requests and ensure they are not called during tests. diff --git a/Gemfile.lock b/Gemfile.lock index 396940a4b6..08d3f4ee84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -630,6 +630,9 @@ GEM simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) + simplecov-json (0.2.3) + json + simplecov simplecov_json_formatter (0.1.4) sinatra (3.1.0) mustermann (~> 3.0) @@ -789,6 +792,7 @@ DEPENDENCIES shoulda-matchers (~> 6.2) simple_form simplecov + simplecov-json sprockets (~> 4.2.1) standard (~> 1.40) stimulus-rails From 349c3a701f6e8b3cb7be25836e670895e2aa8082 Mon Sep 17 00:00:00 2001 From: Steve Brudz Date: Tue, 17 Dec 2024 14:41:35 -0600 Subject: [PATCH 3/4] Support use of multiple test databases --- config/database.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/database.yml b/config/database.yml index 0b4fc4bfbd..62f51a4947 100644 --- a/config/database.yml +++ b/config/database.yml @@ -22,7 +22,7 @@ staging: test: <<: *default - database: diaper_test + database: diaper_test<%= ENV['TEST_ENV_NUMBER'] %> timeout: 5000 # These env vars live in Cloud66 application settings. Don't change From f787a3a42d2cf2e50ea909c32234e1f9bb5e5dd1 Mon Sep 17 00:00:00 2001 From: "phoenix[bot]" Date: Thu, 6 Mar 2025 20:41:59 +0000 Subject: [PATCH 4/4] Add generated tests for app/models/*.rb --- .../tests/app/models/account_request_spec.rb | 291 ++++ .../unit/tests/app/models/adjustment_spec.rb | 203 +++ .../tests/app/models/annual_report_spec.rb | 48 + .../app/models/application_record_spec.rb | 6 + .../unit/tests/app/models/audit_spec.rb | 187 +++ .../tests/app/models/barcode_item_spec.rb | 143 ++ .../unit/tests/app/models/base_item_spec.rb | 39 + .../app/models/broadcast_announcement_spec.rb | 81 ++ .../unit/tests/app/models/county_spec.rb | 58 + .../tests/app/models/distribution_spec.rb | 558 ++++++++ .../tests/app/models/donation_site_spec.rb | 97 ++ .../unit/tests/app/models/donation_spec.rb | 390 ++++++ .../unit/tests/app/models/errors_spec.rb | 189 +++ .../unit/tests/app/models/event_spec.rb | 147 ++ .../tests/app/models/inventory_item_spec.rb | 53 + .../tests/app/models/item_category_spec.rb | 6 + .../unit/tests/app/models/item_spec.rb | 755 ++++++++++ .../unit/tests/app/models/item_unit_spec.rb | 6 + .../tests/app/models/kit_allocation_spec.rb | 6 + .../unit/tests/app/models/kit_spec.rb | 230 ++++ .../unit/tests/app/models/line_item_spec.rb | 44 + .../tests/app/models/manufacturer_spec.rb | 123 ++ .../unit/tests/app/models/ndbn_member_spec.rb | 71 + .../tests/app/models/organization_spec.rb | 1216 +++++++++++++++++ .../app/models/organization_stats_spec.rb | 128 ++ .../tests/app/models/partner_group_spec.rb | 6 + .../unit/tests/app/models/partner_spec.rb | 1182 ++++++++++++++++ .../models/product_drive_participant_spec.rb | 101 ++ .../tests/app/models/product_drive_spec.rb | 444 ++++++ .../unit/tests/app/models/purchase_spec.rb | 404 ++++++ .../unit/tests/app/models/question_spec.rb | 48 + .../tests/app/models/request_item_spec.rb | 147 ++ .../unit/tests/app/models/request_spec.rb | 227 +++ .../unit/tests/app/models/role_spec.rb | 55 + .../tests/app/models/storage_location_spec.rb | 505 +++++++ .../unit/tests/app/models/transfer_spec.rb | 239 ++++ .../unit/tests/app/models/unit_spec.rb | 6 + .../unit/tests/app/models/user_spec.rb | Bin 0 -> 10017 bytes .../unit/tests/app/models/users_role_spec.rb | 61 + .../unit/tests/app/models/vendor_spec.rb | 59 + 40 files changed, 8559 insertions(+) create mode 100644 phoenix-tests/unit/tests/app/models/account_request_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/adjustment_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/annual_report_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/application_record_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/audit_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/barcode_item_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/base_item_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/broadcast_announcement_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/county_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/distribution_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/donation_site_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/donation_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/errors_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/event_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/inventory_item_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/item_category_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/item_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/item_unit_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/kit_allocation_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/kit_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/line_item_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/manufacturer_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/ndbn_member_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/organization_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/organization_stats_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/partner_group_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/partner_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/product_drive_participant_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/product_drive_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/purchase_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/question_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/request_item_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/request_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/role_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/storage_location_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/transfer_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/unit_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/user_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/users_role_spec.rb create mode 100644 phoenix-tests/unit/tests/app/models/vendor_spec.rb diff --git a/phoenix-tests/unit/tests/app/models/account_request_spec.rb b/phoenix-tests/unit/tests/app/models/account_request_spec.rb new file mode 100644 index 0000000000..9b30207812 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/account_request_spec.rb @@ -0,0 +1,291 @@ + +require "rails_helper" + +RSpec.describe AccountRequest do +describe '#get_by_identity_token', :phoenix do + let(:account_request) { create(:account_request) } + let(:identity_token) { JWT.encode({ account_request_id: account_request.id }, Rails.application.secret_key_base, 'HS256') } + + it 'returns the account request when given a valid identity token' do + allow(AccountRequest).to receive(:find_by).with(id: account_request.id).and_return(account_request) + expect(AccountRequest.get_by_identity_token(identity_token)).to eq(account_request) + end + + it 'returns nil when no account request is found for a valid identity token' do + allow(AccountRequest).to receive(:find_by).with(id: account_request.id).and_return(nil) + expect(AccountRequest.get_by_identity_token(identity_token)).to be_nil + end + + it 'returns nil for an invalid identity token due to decoding error' do + invalid_token = 'invalid.token.string' + expect(AccountRequest.get_by_identity_token(invalid_token)).to be_nil + end + + it 'returns nil when a StandardError is raised during decoding' do + allow(JWT).to receive(:decode).and_raise(StandardError) + expect(AccountRequest.get_by_identity_token(identity_token)).to be_nil + end +end +describe '#identity_token', :phoenix do + let(:account_request) { create(:account_request) } + + context 'when the account request is persisted' do + it 'encodes a JWT token with the correct account_request_id' do + token = account_request.identity_token + decoded_token = JWT.decode(token, Rails.application.secret_key_base, true, { algorithm: 'HS256' }) + expect(decoded_token.first['account_request_id']).to eq(account_request.id) + end + end + + context 'when the account request is not persisted' do + let(:account_request) { build(:account_request) } + + it 'raises an error indicating the id is missing' do + expect { account_request.identity_token }.to raise_error('must have an id') + end + end +end +describe '#confirmed?', :phoenix do + let(:account_request) { build(:account_request, status: status) } + + context 'when user is confirmed and admin is not approved' do + let(:status) { 'user_confirmed' } + + it 'returns true when user is confirmed' do + expect(account_request.confirmed?).to eq(true) + end + end + + context 'when user is not confirmed and admin is approved' do + let(:status) { 'admin_approved' } + + it 'returns true when admin is approved' do + expect(account_request.confirmed?).to eq(true) + end + end + + context 'when both user is confirmed and admin is approved' do + let(:status) { 'user_confirmed_and_admin_approved' } + + it 'returns true when both user is confirmed and admin is approved' do + expect(account_request.confirmed?).to eq(true) + end + end + + context 'when neither user is confirmed nor admin is approved' do + let(:status) { 'pending' } + + it 'returns false when neither user is confirmed nor admin is approved' do + expect(account_request.confirmed?).to eq(false) + end + end +end +describe '#processed?', :phoenix do + let(:organization) { build(:organization) } + let(:account_request_with_org) { build(:account_request, organization: organization) } + let(:account_request_without_org) { build(:account_request, organization: nil) } + + it 'returns true when organization is present' do + expect(account_request_with_org.processed?).to be true + end + + it 'returns false when organization is not present' do + expect(account_request_without_org.processed?).to be false + end +end +describe '#can_be_closed?', :phoenix do + let(:account_request) { build(:account_request, status: status) } + + context 'when status is started' do + let(:status) { 'started' } + + it 'returns true when the status is started' do + expect(account_request.can_be_closed?).to eq(true) + end + end + + context 'when status is user_confirmed' do + let(:status) { 'user_confirmed' } + + it 'returns true when the status is user_confirmed' do + expect(account_request.can_be_closed?).to eq(true) + end + end + + context 'when status is neither started nor user_confirmed' do + let(:status) { 'pending' } + + it 'returns false when the status is neither started nor user_confirmed' do + expect(account_request.can_be_closed?).to eq(false) + end + end +end +describe '#confirm!', :phoenix do + let(:account_request) { create(:account_request) } + + it 'updates confirmed_at to current time' do + account_request.confirm! + expect(account_request.confirmed_at).to be_within(1.second).of(Time.current) + end + + it 'updates status to user_confirmed' do + account_request.confirm! + expect(account_request.status).to eq('user_confirmed') + end + + it 'enqueues an approval request email' do + expect { account_request.confirm! }.to have_enqueued_job.on_queue('mailers') + end + + describe 'when update fails' do + before do + allow(account_request).to receive(:update!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises ActiveRecord::RecordInvalid error' do + expect { account_request.confirm! }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + describe 'when email delivery fails' do + before do + allow(AccountRequestMailer).to receive_message_chain(:approval_request, :deliver_later).and_raise(StandardError) + end + + it 'raises StandardError' do + expect { account_request.confirm! }.to raise_error(StandardError) + end + end +end +describe '#reject!', :phoenix do + let(:account_request) { create(:account_request) } + let(:rejection_reason) { 'Insufficient information provided' } + + it 'updates the status to rejected' do + account_request.reject!(rejection_reason) + expect(account_request.status).to eq('rejected') + end + + it 'sets the rejection reason' do + account_request.reject!(rejection_reason) + expect(account_request.rejection_reason).to eq(rejection_reason) + end + + it 'sends a rejection email' do + mailer_double = instance_double(AccountRequestMailer) + allow(AccountRequestMailer).to receive(:rejection).with(account_request_id: account_request.id).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_later) + account_request.reject!(rejection_reason) + end + + describe 'when update fails' do + before do + allow(account_request).to receive(:update!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'does not send a rejection email' do + expect(AccountRequestMailer).not_to receive(:rejection) + expect { account_request.reject!(rejection_reason) }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + describe 'when email delivery fails' do + before do + allow(AccountRequestMailer).to receive(:rejection).and_raise(Net::SMTPFatalError) + end + + it 'raises an email delivery failure error' do + expect { account_request.reject!(rejection_reason) }.to raise_error(Net::SMTPFatalError) + end + end +end +describe '#close!', :phoenix do + let(:account_request) { build(:account_request, status: account_status) } + let(:account_status) { 'open' } + + it 'raises an error if the account cannot be closed' do + allow(account_request).to receive(:can_be_closed?).and_return(false) + expect { account_request.close!('Some reason') }.to raise_error('Cannot be closed from this state') + end + + context 'when the account can be closed' do + before do + allow(account_request).to receive(:can_be_closed?).and_return(true) + end + + it 'updates the status to admin_closed' do + account_request.close!('Some reason') + expect(account_request.status).to eq('admin_closed') + end + + it 'sets the rejection reason' do + account_request.close!('Some reason') + expect(account_request.rejection_reason).to eq('Some reason') + end + end +end +describe '#email_not_already_used_by_organization', :phoenix do + let(:organization) { build(:organization, email: 'unique@example.com') } + let(:existing_organization) { create(:organization, email: 'existing@example.com') } + + it 'does not add an error if the email is not associated with any organization' do + allow(Organization).to receive(:find_by).with(email: 'unique@example.com').and_return(nil) + organization.email_not_already_used_by_organization + expect(organization.errors[:email]).to be_empty + end + + it 'does not add an error if the email is associated with the current organization' do + allow(Organization).to receive(:find_by).with(email: 'unique@example.com').and_return(organization) + organization.email_not_already_used_by_organization + expect(organization.errors[:email]).to be_empty + end + + it 'adds an error if the email is associated with a different organization' do + allow(Organization).to receive(:find_by).with(email: 'existing@example.com').and_return(existing_organization) + organization.email = 'existing@example.com' + organization.email_not_already_used_by_organization + expect(organization.errors[:email]).to include('already used by an existing Organization') + end +end +describe '#email_not_already_used_by_user', :phoenix do + let(:organization) { create(:organization) } + let(:user_with_same_email) { build(:user, email: 'test@example.com', organization: user_organization) } + let(:user_organization) { nil } + + it 'adds an error when a user with the same email exists and no organization is provided' do + allow(User).to receive(:find_by).with(email: 'test@example.com').and_return(user_with_same_email) + account_request = build(:account_request, email: 'test@example.com', organization: nil) + account_request.email_not_already_used_by_user + expect(account_request.errors[:email]).to include('already used by an existing User') + end + + context 'when a different organization is provided' do + let(:user_organization) { create(:organization) } + + it 'adds an error when a user with the same email exists and a different organization is provided' do + allow(User).to receive(:find_by).with(email: 'test@example.com').and_return(user_with_same_email) + account_request = build(:account_request, email: 'test@example.com', organization: organization) + account_request.email_not_already_used_by_user + expect(account_request.errors[:email]).to include('already used by an existing User') + end + end + + context 'when the same organization is provided' do + let(:user_organization) { organization } + + it 'does not add an error when a user with the same email exists and the same organization is provided' do + allow(User).to receive(:find_by).with(email: 'test@example.com').and_return(user_with_same_email) + account_request = build(:account_request, email: 'test@example.com', organization: organization) + account_request.email_not_already_used_by_user + expect(account_request.errors[:email]).to be_empty + end + end + + it 'does not add an error when no user with the same email exists' do + allow(User).to receive(:find_by).with(email: 'test@example.com').and_return(nil) + account_request = build(:account_request, email: 'test@example.com', organization: organization) + account_request.email_not_already_used_by_user + expect(account_request.errors[:email]).to be_empty + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/adjustment_spec.rb b/phoenix-tests/unit/tests/app/models/adjustment_spec.rb new file mode 100644 index 0000000000..b4c0134e62 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/adjustment_spec.rb @@ -0,0 +1,203 @@ + +require "rails_helper" + +RSpec.describe Adjustment do +describe "#storage_locations_adjusted_for", :phoenix do + let(:organization) { create(:organization) } + let(:storage_location_1) { create(:storage_location, organization: organization, name: 'Location A') } + let(:storage_location_2) { create(:storage_location, organization: organization, name: 'Location B') } + let(:discarded_storage_location) { create(:storage_location, organization: organization, discarded_at: Time.current) } + + it "returns non-discarded storage locations for the organization" do + storage_location_1 + storage_location_2 + discarded_storage_location + expect(Adjustment.storage_locations_adjusted_for(organization)).to match_array([storage_location_1, storage_location_2]) + end + + it "excludes discarded storage locations" do + storage_location_1 + discarded_storage_location + expect(Adjustment.storage_locations_adjusted_for(organization)).not_to include(discarded_storage_location) + end + + it "returns an empty array when no storage locations exist" do + expect(Adjustment.storage_locations_adjusted_for(organization)).to eq([]) + end + + it "returns storage locations sorted by name" do + storage_location_1 + storage_location_2 + expect(Adjustment.storage_locations_adjusted_for(organization)).to eq([storage_location_1, storage_location_2].sort_by(&:name)) + end + + it "handles nil organization gracefully without error" do + expect { Adjustment.storage_locations_adjusted_for(nil) }.not_to raise_error + end + + it "returns an empty array for nil organization" do + expect(Adjustment.storage_locations_adjusted_for(nil)).to eq([]) + end +end +describe '#split_difference', :phoenix do + let(:adjustment_with_positive_quantities) { build(:adjustment, :with_items, item_quantity: 5) } + let(:adjustment_with_non_positive_quantities) { build(:adjustment, :with_items, item_quantity: -5) } + let(:adjustment_with_mixed_quantities) do + adjustment = build(:adjustment) + adjustment.line_items << build(:line_item, quantity: 5, itemizable: adjustment) + adjustment.line_items << build(:line_item, quantity: -5, itemizable: adjustment) + adjustment + end + let(:empty_adjustment) { build(:adjustment, line_items: []) } + + it 'returns only positive quantities in increasing_adjustment' do + increasing_adjustment, _ = adjustment_with_positive_quantities.split_difference + expect(increasing_adjustment.line_items.map(&:quantity)).to all(be > 0) + end + + it 'returns empty decreasing_adjustment for positive quantities' do + _, decreasing_adjustment = adjustment_with_positive_quantities.split_difference + expect(decreasing_adjustment.line_items).to be_empty + end + + it 'returns empty increasing_adjustment for non-positive quantities' do + increasing_adjustment, _ = adjustment_with_non_positive_quantities.split_difference + expect(increasing_adjustment.line_items).to be_empty + end + + it 'returns only negative quantities in decreasing_adjustment' do + _, decreasing_adjustment = adjustment_with_non_positive_quantities.split_difference + expect(decreasing_adjustment.line_items.map(&:quantity)).to all(be < 0) + end + + it 'correctly splits mixed quantities into increasing_adjustment' do + increasing_adjustment, _ = adjustment_with_mixed_quantities.split_difference + expect(increasing_adjustment.line_items.map(&:quantity)).to all(be > 0) + end + + it 'correctly splits mixed quantities into decreasing_adjustment' do + _, decreasing_adjustment = adjustment_with_mixed_quantities.split_difference + expect(decreasing_adjustment.line_items.map(&:quantity)).to all(be < 0) + end + + it 'returns empty adjustments for empty line_items' do + increasing_adjustment, decreasing_adjustment = empty_adjustment.split_difference + expect(increasing_adjustment.line_items).to be_empty + expect(decreasing_adjustment.line_items).to be_empty + end + + it 'modifies the quantity of line_items in decreasing_adjustment to negative' do + _, decreasing_adjustment = adjustment_with_mixed_quantities.split_difference + expect(decreasing_adjustment.line_items.map(&:quantity)).to all(be < 0) + end +end +describe '.csv_export_headers', :phoenix do + let(:expected_headers) { ["Created", "Organization", "Storage Location", "Comment", "Changes"] } + + it 'returns an array' do + expect(Adjustment.csv_export_headers).to be_an(Array) + end + + it 'contains the expected headers' do + expect(Adjustment.csv_export_headers).to match_array(expected_headers) + end + + it 'returns headers in the correct order' do + expect(Adjustment.csv_export_headers).to eq(expected_headers) + end +end +describe '#csv_export_attributes', :phoenix do + let(:organization) { build(:organization, name: 'Test Organization') } + let(:storage_location) { build(:storage_location, name: 'Test Location', organization: organization) } + let(:line_item) { build(:line_item, :adjustment) } + let(:adjustment) { build(:adjustment, organization: organization, storage_location: storage_location, comment: 'Test Comment', line_items: [line_item]) } + + it 'returns formatted created_at date' do + expect(adjustment.csv_export_attributes[0]).to eq(adjustment.created_at.strftime('%F')) + end + + it 'returns organization name' do + expect(adjustment.csv_export_attributes[1]).to eq('Test Organization') + end + + it 'returns storage location name' do + expect(adjustment.csv_export_attributes[2]).to eq('Test Location') + end + + it 'returns comment' do + expect(adjustment.csv_export_attributes[3]).to eq('Test Comment') + end + + it 'returns line items count' do + expect(adjustment.csv_export_attributes[4]).to eq(1) + end + + context 'when organization is nil' do + let(:adjustment) { build(:adjustment, organization: nil, storage_location: storage_location, line_items: [line_item]) } + + it 'handles the nil organization case' do + expect(adjustment.csv_export_attributes[1]).to be_nil + end + end + + context 'when storage_location is nil' do + let(:adjustment) { build(:adjustment, organization: organization, storage_location: nil, line_items: [line_item]) } + + it 'handles the nil storage_location case' do + expect(adjustment.csv_export_attributes[2]).to be_nil + end + end + + context 'when comment is nil' do + let(:adjustment) { build(:adjustment, organization: organization, storage_location: storage_location, comment: nil, line_items: [line_item]) } + + it 'handles the nil comment case' do + expect(adjustment.csv_export_attributes[3]).to be_nil + end + end + + context 'when line_items is empty' do + let(:adjustment) { build(:adjustment, organization: organization, storage_location: storage_location, line_items: []) } + + it 'handles the empty line_items case' do + expect(adjustment.csv_export_attributes[4]).to eq(0) + end + end + + context 'when created_at is nil' do + let(:adjustment) { build(:adjustment, organization: organization, storage_location: storage_location, created_at: nil, line_items: [line_item]) } + + it 'handles the nil created_at case' do + expect(adjustment.csv_export_attributes[0]).to be_nil + end + end +end +describe '#storage_locations_belong_to_organization', :phoenix do + let(:organization) { build(:organization) } + let(:storage_location) { build(:storage_location, organization: organization) } + let(:adjustment) { build(:adjustment, organization: organization, storage_location: storage_location) } + + it 'returns early if organization is nil' do + adjustment.organization = nil + adjustment.storage_locations_belong_to_organization + expect(adjustment.errors[:storage_location]).to be_empty + end + + context 'when storage location belongs to the organization' do + it 'does not add any errors' do + adjustment.storage_locations_belong_to_organization + expect(adjustment.errors[:storage_location]).to be_empty + end + end + + context 'when storage location does not belong to the organization' do + let(:other_organization) { build(:organization) } + let(:storage_location) { build(:storage_location, organization: other_organization) } + + it 'adds an error to storage_location' do + adjustment.storage_locations_belong_to_organization + expect(adjustment.errors[:storage_location]).to include('storage location must belong to organization') + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/annual_report_spec.rb b/phoenix-tests/unit/tests/app/models/annual_report_spec.rb new file mode 100644 index 0000000000..51530557be --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/annual_report_spec.rb @@ -0,0 +1,48 @@ + +require "rails_helper" + +RSpec.describe AnnualReport do +describe '#each_report', :phoenix do + let(:annual_report) { AnnualReport.new(all_reports: all_reports) } + + context 'when all_reports is empty' do + let(:all_reports) { [] } + + it 'does not yield anything' do + expect { |b| annual_report.each_report(&b) }.not_to yield_control + end + end + + context 'when all_reports has one report' do + let(:all_reports) { [{'name' => 'Report 1', 'entries' => ['entry1', 'entry2']}] } + + it 'yields once with correct name and entries' do + expect { |b| annual_report.each_report(&b) }.to yield_with_args('Report 1', ['entry1', 'entry2']) + end + end + + context 'when all_reports has multiple reports' do + let(:all_reports) { [{'name' => 'Report 1', 'entries' => ['entry1', 'entry2']}, {'name' => 'Report 2', 'entries' => ['entry3', 'entry4']}] } + + it 'yields for each report with correct name and entries' do + expect { |b| annual_report.each_report(&b) }.to yield_successive_args(['Report 1', ['entry1', 'entry2']], ['Report 2', ['entry3', 'entry4']]) + end + end + + context 'when a report is missing the name key' do + let(:all_reports) { [{'entries' => ['entry1', 'entry2']}] } + + it 'yields with nil for missing name key' do + expect { |b| annual_report.each_report(&b) }.to yield_with_args(nil, ['entry1', 'entry2']) + end + end + + context 'when a report is missing the entries key' do + let(:all_reports) { [{'name' => 'Report 1'}] } + + it 'yields with nil for missing entries key' do + expect { |b| annual_report.each_report(&b) }.to yield_with_args('Report 1', nil) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/application_record_spec.rb b/phoenix-tests/unit/tests/app/models/application_record_spec.rb new file mode 100644 index 0000000000..58c5e84c03 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/application_record_spec.rb @@ -0,0 +1,6 @@ + +require "rails_helper" + +RSpec.describe ApplicationRecord do + +end diff --git a/phoenix-tests/unit/tests/app/models/audit_spec.rb b/phoenix-tests/unit/tests/app/models/audit_spec.rb new file mode 100644 index 0000000000..f499328309 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/audit_spec.rb @@ -0,0 +1,187 @@ + +require "rails_helper" + +RSpec.describe Audit do +describe '#storage_locations_audited_for', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:discarded_storage_location) { create(:storage_location, organization: organization, discarded_at: Time.current) } + let(:audit) { create(:audit, organization: organization, storage_location: storage_location) } + + it 'returns storage locations that are not discarded for the organization' do + expect(Audit.storage_locations_audited_for(organization)).to include(storage_location) + end + + it 'does not return discarded storage locations for the organization' do + expect(Audit.storage_locations_audited_for(organization)).not_to include(discarded_storage_location) + end + + it 'returns an empty array when the organization has no storage locations' do + new_organization = create(:organization) + expect(Audit.storage_locations_audited_for(new_organization)).to be_empty + end + + it 'returns only non-discarded storage locations when some are discarded' do + expect(Audit.storage_locations_audited_for(organization)).to eq([storage_location]) + end + + it 'handles the case where the organization does not exist' do + non_existent_organization = build(:organization) + expect(Audit.storage_locations_audited_for(non_existent_organization)).to be_empty + end +end +describe '#finalized_since?', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:item) { create(:item) } + let(:donation) { create(:donation, organization: organization, storage_location: storage_location, issued_at: 1.day.ago) } + let(:audit) { create(:audit, :with_items, organization: organization, storage_location: storage_location, status: :finalized, item: item) } + + it 'returns true when audits match all conditions' do + allow(donation).to receive(:line_items).and_return([build(:line_item, item: item, itemizable: donation)]) + expect(Audit.finalized_since?(donation, storage_location.id)).to be true + end + + describe 'when no audits have the finalized status' do + before { audit.update(status: :in_progress) } + + it 'returns false' do + expect(Audit.finalized_since?(donation, storage_location.id)).to be false + end + end + + describe 'when audits are not in the specified location IDs' do + let(:other_location) { create(:storage_location, organization: organization) } + + it 'returns false' do + expect(Audit.finalized_since?(donation, other_location.id)).to be false + end + end + + describe 'when audits are updated before the itemizable created_at' do + before { audit.update(updated_at: 2.days.ago) } + + it 'returns false' do + expect(Audit.finalized_since?(donation, storage_location.id)).to be false + end + end + + describe 'when no line items match the item IDs' do + before { allow(donation).to receive(:line_items).and_return([]) } + + it 'returns false' do + expect(Audit.finalized_since?(donation, storage_location.id)).to be false + end + end +end +describe "#user_is_organization_admin_of_the_organization", :phoenix do + let(:organization) { create(:organization) } + let(:user) { create(:user) } + let(:audit) { build(:audit, organization: organization, user: user) } + + it "returns without error when organization is nil" do + audit.organization = nil + audit.user_is_organization_admin_of_the_organization + expect(audit.errors[:user]).to be_empty + end + + context "when organization is not nil" do + let(:user_with_role) { create(:user) } + let(:user_without_role) { create(:user) } + + before do + allow(user_with_role).to receive(:has_role?).with(Role::ORG_ADMIN, organization).and_return(true) + allow(user_without_role).to receive(:has_role?).with(Role::ORG_ADMIN, organization).and_return(false) + end + + it "does not add error when user has ORG_ADMIN role" do + audit.user = user_with_role + audit.user_is_organization_admin_of_the_organization + expect(audit.errors[:user]).to be_empty + end + + it "adds error when user does not have ORG_ADMIN role" do + audit.user = user_without_role + audit.user_is_organization_admin_of_the_organization + expect(audit.errors[:user]).to include("user must be an organization admin of the organization") + end + end +end +describe "#line_items_unique_by_item_id", :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:audit) { build(:audit, organization: organization, storage_location: storage_location) } + + context "when there are no line items" do + it "does not add any errors" do + audit.line_items_unique_by_item_id + expect(audit.errors[:base]).to be_empty + end + end + + context "when all item IDs are unique" do + let(:unique_item) { create(:item, organization: organization) } + before do + audit.line_items << build(:line_item, item: unique_item, itemizable: audit) + end + + it "does not add any errors" do + audit.line_items_unique_by_item_id + expect(audit.errors[:base]).to be_empty + end + end + + context "when there are duplicate item IDs" do + let(:duplicate_item) { create(:item, organization: organization) } + before do + 2.times { audit.line_items << build(:line_item, item: duplicate_item, itemizable: audit) } + end + + it "adds an error for duplicate item IDs" do + audit.line_items_unique_by_item_id + expect(audit.errors[:base]).to include("You have entered at least one duplicate item: #{duplicate_item.name}") + end + end + + context "when there are multiple sets of duplicate item IDs" do + let(:duplicate_item1) { create(:item, organization: organization) } + let(:duplicate_item2) { create(:item, organization: organization) } + before do + 2.times { audit.line_items << build(:line_item, item: duplicate_item1, itemizable: audit) } + 2.times { audit.line_items << build(:line_item, item: duplicate_item2, itemizable: audit) } + end + + it "adds an error for each set of duplicate item IDs" do + audit.line_items_unique_by_item_id + expect(audit.errors[:base]).to include("You have entered at least one duplicate item: #{duplicate_item1.name}, #{duplicate_item2.name}") + end + end +end +describe '#line_items_quantity_is_not_negative', :phoenix do + let(:audit) { build(:audit, :with_items, item_quantity: item_quantity) } + + context 'when quantity is 0' do + let(:item_quantity) { 0 } + + it 'returns true for quantity of 0' do + expect(audit.line_items_quantity_is_not_negative).to be_truthy + end + end + + context 'when quantity is positive' do + let(:item_quantity) { 10 } + + it 'returns true for positive quantity' do + expect(audit.line_items_quantity_is_not_negative).to be_truthy + end + end + + context 'when quantity is negative' do + let(:item_quantity) { -5 } + + it 'returns false for negative quantity' do + expect(audit.line_items_quantity_is_not_negative).to be_falsey + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/barcode_item_spec.rb b/phoenix-tests/unit/tests/app/models/barcode_item_spec.rb new file mode 100644 index 0000000000..05ffdd5472 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/barcode_item_spec.rb @@ -0,0 +1,143 @@ + +require "rails_helper" + +RSpec.describe BarcodeItem do +describe '#to_h', :phoenix do + let(:barcode_item) { build(:barcode_item, barcodeable_id: barcodeable_id, barcodeable_type: barcodeable_type, quantity: quantity) } + let(:barcodeable_id) { 1 } + let(:barcodeable_type) { 'Item' } + let(:quantity) { 10 } + + it 'returns a hash with the correct keys and values' do + expect(barcode_item.to_h).to eq({ barcodeable_id: 1, barcodeable_type: 'Item', quantity: 10 }) + end + + describe 'when barcodeable_id is nil' do + let(:barcodeable_id) { nil } + + it 'returns a hash with nil barcodeable_id' do + expect(barcode_item.to_h).to eq({ barcodeable_id: nil, barcodeable_type: 'Item', quantity: 10 }) + end + end + + describe 'when barcodeable_type is nil' do + let(:barcodeable_type) { nil } + + it 'returns a hash with nil barcodeable_type' do + expect(barcode_item.to_h).to eq({ barcodeable_id: 1, barcodeable_type: nil, quantity: 10 }) + end + end + + describe 'when quantity is nil' do + let(:quantity) { nil } + + it 'returns a hash with nil quantity' do + expect(barcode_item.to_h).to eq({ barcodeable_id: 1, barcodeable_type: 'Item', quantity: nil }) + end + end + + describe 'when attributes have non-standard values' do + let(:barcodeable_id) { 999 } + let(:barcodeable_type) { 'NonStandardType' } + let(:quantity) { 999 } + + it 'returns a hash with non-standard barcodeable_id' do + expect(barcode_item.to_h).to eq({ barcodeable_id: 999, barcodeable_type: 'NonStandardType', quantity: 999 }) + end + + it 'returns a hash with non-standard barcodeable_type' do + expect(barcode_item.to_h).to eq({ barcodeable_id: 999, barcodeable_type: 'NonStandardType', quantity: 999 }) + end + + it 'returns a hash with non-standard quantity' do + expect(barcode_item.to_h).to eq({ barcodeable_id: 999, barcodeable_type: 'NonStandardType', quantity: 999 }) + end + end +end +describe '.csv_export_headers', :phoenix do + it 'returns an array with the correct headers' do + expect(BarcodeItem.csv_export_headers).to eq(["Item Type", "Quantity in the Box", "Barcode"]) + end +end +describe '#csv_export_attributes', :phoenix do + let(:barcodeable_with_name) { create(:item, name: 'Test Item') } + let(:barcode_item_with_name) { build(:barcode_item, barcodeable: barcodeable_with_name, quantity: 10, value: '100') } + let(:barcode_item_nil_barcodeable) { build(:barcode_item, barcodeable: nil, quantity: 10, value: '100') } + let(:barcode_item_zero_quantity) { build(:barcode_item, barcodeable: barcodeable_with_name, quantity: 0, value: '100') } + let(:barcode_item_nil_value) { build(:barcode_item, barcodeable: barcodeable_with_name, quantity: 10, value: nil) } + + it 'returns an array with barcodeable name, quantity, and value' do + expect(barcode_item_with_name.csv_export_attributes).to eq(['Test Item', 10, '100']) + end + + describe 'when barcodeable is nil' do + it 'returns an array with nil for barcodeable name' do + expect(barcode_item_nil_barcodeable.csv_export_attributes).to eq([nil, 10, '100']) + end + end + + describe 'when barcodeable has a name' do + it 'includes the name in the array' do + expect(barcode_item_with_name.csv_export_attributes).to include('Test Item') + end + end + + describe 'when quantity is zero' do + it 'includes zero quantity in the array' do + expect(barcode_item_zero_quantity.csv_export_attributes).to eq(['Test Item', 0, '100']) + end + end + + describe 'when value is nil' do + it 'returns an array with nil for value' do + expect(barcode_item_nil_value.csv_export_attributes).to eq(['Test Item', 10, nil]) + end + end +end +describe '#global?', :phoenix do + let(:global_barcode_item) { build(:barcode_item, barcodeable_type: 'BaseItem') } + let(:non_global_barcode_item) { build(:barcode_item, barcodeable_type: 'NonBaseItem') } + + it 'returns true when barcodeable_type is BaseItem' do + expect(global_barcode_item.global?).to be true + end + + it 'returns false when barcodeable_type is not BaseItem' do + expect(non_global_barcode_item.global?).to be false + end +end +describe "#unique_barcode_value", :phoenix do + let(:organization) { create(:organization) } + let(:base_item) { create(:base_item) } + + context "when the barcode is global" do + let(:global_barcode_item) { build(:global_barcode_item, value: "123456789012", barcodeable: base_item) } + + it "adds an error if the barcode value already exists for a different BarcodeItem with barcodeable_type as 'BaseItem'" do + create(:global_barcode_item, value: "123456789012", barcodeable: base_item) + global_barcode_item.valid? + expect(global_barcode_item.errors[:value]).to include("That barcode value already exists") + end + + it "does not add an error if the barcode value does not exist for any other BarcodeItem" do + global_barcode_item.valid? + expect(global_barcode_item.errors[:value]).to be_empty + end + end + + context "when the barcode is not global" do + let(:barcode_item) { build(:barcode_item, value: "123456789012", organization: organization) } + + it "adds an error if the barcode value already exists for a different BarcodeItem within the same organization" do + create(:barcode_item, value: "123456789012", organization: organization) + barcode_item.valid? + expect(barcode_item.errors[:value]).to include("That barcode value already exists") + end + + it "does not add an error if the barcode value does not exist for any other BarcodeItem within the same organization" do + barcode_item.valid? + expect(barcode_item.errors[:value]).to be_empty + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/base_item_spec.rb b/phoenix-tests/unit/tests/app/models/base_item_spec.rb new file mode 100644 index 0000000000..8496b77ba5 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/base_item_spec.rb @@ -0,0 +1,39 @@ + +require "rails_helper" + +RSpec.describe BaseItem do +describe '#to_h', :phoenix do + let(:base_item) { build(:base_item, partner_key: partner_key, name: name) } + let(:partner_key) { 'default_partner_key' } + let(:name) { 'default_name' } + + it 'returns a hash with partner_key and name when both are present' do + expect(base_item.to_h).to eq({ partner_key: 'default_partner_key', name: 'default_name' }) + end + + context 'when partner_key is nil' do + let(:partner_key) { nil } + + it 'returns a hash with nil partner_key' do + expect(base_item.to_h).to eq({ partner_key: nil, name: 'default_name' }) + end + end + + context 'when name is nil' do + let(:name) { nil } + + it 'returns a hash with nil name' do + expect(base_item.to_h).to eq({ partner_key: 'default_partner_key', name: nil }) + end + end + + context 'when both partner_key and name are nil' do + let(:partner_key) { nil } + let(:name) { nil } + + it 'returns a hash with both values nil' do + expect(base_item.to_h).to eq({ partner_key: nil, name: nil }) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/broadcast_announcement_spec.rb b/phoenix-tests/unit/tests/app/models/broadcast_announcement_spec.rb new file mode 100644 index 0000000000..f00ffdc77c --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/broadcast_announcement_spec.rb @@ -0,0 +1,81 @@ + +require "rails_helper" + +RSpec.describe BroadcastAnnouncement do +describe '#expired?', :phoenix do + let(:broadcast_announcement) { build(:broadcast_announcement, expiry: expiry_date) } + + context 'when expiry is nil' do + let(:expiry_date) { nil } + + it 'returns false when expiry is nil' do + expect(broadcast_announcement.expired?).to eq(false) + end + end + + context 'when expiry is in the past' do + let(:expiry_date) { Time.zone.today - 1.day } + + it 'returns true when expiry is in the past' do + expect(broadcast_announcement.expired?).to eq(true) + end + end + + context 'when expiry is today' do + let(:expiry_date) { Time.zone.today } + + it 'returns false when expiry is today' do + expect(broadcast_announcement.expired?).to eq(false) + end + end + + context 'when expiry is in the future' do + let(:expiry_date) { Time.zone.today + 1.day } + + it 'returns false when expiry is in the future' do + expect(broadcast_announcement.expired?).to eq(false) + end + end +end +describe '#filter_announcements', :phoenix do + let(:organization) { create(:organization) } + let(:other_organization) { create(:organization) } + let(:today) { Time.zone.today } + + let!(:announcement_with_org) { create(:broadcast_announcement, organization: organization, expiry: today + 1.day) } + let!(:announcement_with_other_org) { create(:broadcast_announcement, organization: other_organization, expiry: today + 1.day) } + let!(:announcement_with_nil_expiry) { create(:broadcast_announcement, organization: organization, expiry: nil) } + let!(:announcement_with_future_expiry) { create(:broadcast_announcement, organization: organization, expiry: today + 1.day) } + let!(:announcement_with_past_expiry) { create(:broadcast_announcement, organization: organization, expiry: today - 1.day) } + + it 'includes announcements with the specified organization_id' do + result = BroadcastAnnouncement.filter_announcements(organization.id) + expect(result).to include(announcement_with_org) + end + + it 'excludes announcements with a different organization_id' do + result = BroadcastAnnouncement.filter_announcements(organization.id) + expect(result).not_to include(announcement_with_other_org) + end + + it 'includes announcements with expiry as nil' do + result = BroadcastAnnouncement.filter_announcements(organization.id) + expect(result).to include(announcement_with_nil_expiry) + end + + it 'includes announcements with expiry date greater than or equal to today' do + result = BroadcastAnnouncement.filter_announcements(organization.id) + expect(result).to include(announcement_with_future_expiry) + end + + it 'excludes announcements with expiry date less than today' do + result = BroadcastAnnouncement.filter_announcements(organization.id) + expect(result).not_to include(announcement_with_past_expiry) + end + + it 'orders announcements by created_at in descending order' do + result = BroadcastAnnouncement.filter_announcements(organization.id) + expect(result).to eq([announcement_with_future_expiry, announcement_with_nil_expiry, announcement_with_org]) + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/county_spec.rb b/phoenix-tests/unit/tests/app/models/county_spec.rb new file mode 100644 index 0000000000..7284778960 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/county_spec.rb @@ -0,0 +1,58 @@ + +require "rails_helper" + +RSpec.describe County do +describe "#in_category_name_order", :phoenix do + let(:county_us) { create(:county, category: "US_County", name: "Alpha") } + let(:county_foreign) { create(:county, category: "Foreign_County", name: "Beta") } + let(:county_same_category_1) { create(:county, category: "US_County", name: "Beta") } + let(:county_same_category_2) { create(:county, category: "US_County", name: "Alpha") } + let(:county_unknown_category) { create(:county, category: "Unknown_County", name: "Gamma") } + + before do + county_us + county_foreign + county_same_category_1 + county_same_category_2 + county_unknown_category + end + + it "orders counties by category according to SORT_ORDER" do + sorted_counties = County.in_category_name_order + expect(sorted_counties.map(&:category)).to eq(["US_County", "US_County", "US_County", "Foreign_County", "Unknown_County"]) + end + + it "orders counties with the same category by name" do + sorted_counties = County.in_category_name_order + expect(sorted_counties.map(&:name)).to eq(["Alpha", "Alpha", "Beta", "Beta", "Gamma"]) + end + + describe "when there are no counties" do + it "returns an empty array" do + County.delete_all + expect(County.in_category_name_order).to eq([]) + end + end + + describe "when all counties have the same category" do + before do + County.delete_all + create(:county, category: "US_County", name: "Gamma") + create(:county, category: "US_County", name: "Alpha") + create(:county, category: "US_County", name: "Beta") + end + + it "orders counties by name only" do + sorted_counties = County.in_category_name_order + expect(sorted_counties.map(&:name)).to eq(["Alpha", "Beta", "Gamma"]) + end + end + + describe "when categories are not in SORT_ORDER" do + it "places them after the known categories" do + sorted_counties = County.in_category_name_order + expect(sorted_counties.last.category).to eq("Unknown_County") + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/distribution_spec.rb b/phoenix-tests/unit/tests/app/models/distribution_spec.rb new file mode 100644 index 0000000000..5b4d2bbd47 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/distribution_spec.rb @@ -0,0 +1,558 @@ + +require "rails_helper" + +RSpec.describe Distribution do +describe '#distributed_at', :phoenix do + let(:distribution_midnight) { build(:distribution, issued_at: issued_at_midnight) } + let(:distribution_not_midnight) { build(:distribution, issued_at: issued_at_not_midnight) } + let(:issued_at_midnight) { Time.zone.now.midnight } + let(:issued_at_not_midnight) { Time.zone.now.change(hour: 10, min: 0, sec: 0) } + + context 'when issued_at is midnight' do + it 'returns the distribution date format' do + expect(distribution_midnight.distributed_at).to eq(issued_at_midnight.to_fs(:distribution_date)) + end + end + + context 'when issued_at is not midnight' do + it 'returns the distribution date time format' do + expect(distribution_not_midnight.distributed_at).to eq(issued_at_not_midnight.to_fs(:distribution_date_time)) + end + end +end +describe '#combine_duplicates', :phoenix do + let(:distribution) { build(:distribution) } + + context 'when there are no line items' do + it 'does not change the line items size' do + expect { distribution.combine_duplicates }.not_to change { distribution.line_items.size } + end + end + + context 'when there are line items with non-zero quantities' do + let(:distribution) { build(:distribution, :with_items, item_quantity: 5) } + + it 'reduces the line items size by one' do + expect { distribution.combine_duplicates }.to change { distribution.line_items.size }.by(-1) + end + end + + context 'when there are invalid line items' do + let(:distribution) { build(:distribution, :with_items, item_quantity: -1) } + + it 'does not change the line items size' do + expect { distribution.combine_duplicates }.not_to change { distribution.line_items.size } + end + end + + context 'when there are line items with zero quantities' do + let(:distribution) { build(:distribution, :with_items, item_quantity: 0) } + + it 'does not change the line items size' do + expect { distribution.combine_duplicates }.not_to change { distribution.line_items.size } + end + end + + context 'when there are line items with the same item_id' do + let(:item) { create(:item) } + let(:distribution) { build(:distribution, :with_items, item: item, item_quantity: 5) } + + before do + distribution.line_items << build(:line_item, item: item, quantity: 5, itemizable: distribution) + end + + it 'aggregates quantities for line items with the same item_id' do + expect { distribution.combine_duplicates }.to change { distribution.line_items.first.quantity }.from(5).to(10) + end + end +end +describe '#copy_line_items', :phoenix do + let(:distribution) { create(:distribution) } + let(:donation) { create(:donation) } + + context 'when there are no line items' do + it 'does not change the line items count' do + expect { distribution.copy_line_items(donation.id) }.not_to change { distribution.line_items.count } + end + end + + context 'when there is a single line item' do + let!(:line_item) { create(:line_item, :for_donation, itemizable: donation) } + + it 'increases the line items count by 1' do + expect { distribution.copy_line_items(donation.id) }.to change { distribution.line_items.count }.by(1) + end + + it 'copies the attributes of the line item' do + distribution.copy_line_items(donation.id) + copied_item = distribution.line_items.last + expect(copied_item.attributes.except('id', 'created_at', 'updated_at')).to eq(line_item.attributes.except('id', 'created_at', 'updated_at')) + end + end + + context 'when there are multiple line items' do + let!(:line_items) { create_list(:line_item, 3, :for_donation, itemizable: donation) } + + it 'increases the line items count by the number of line items' do + expect { distribution.copy_line_items(donation.id) }.to change { distribution.line_items.count }.by(3) + end + + it 'copies the attributes of each line item' do + distribution.copy_line_items(donation.id) + copied_items = distribution.line_items.order(:id).last(3) + line_items.each_with_index do |line_item, index| + expect(copied_items[index].attributes.except('id', 'created_at', 'updated_at')).to eq(line_item.attributes.except('id', 'created_at', 'updated_at')) + end + end + end + + context 'when an error occurs during line item creation' do + before do + allow_any_instance_of(LineItem).to receive(:save).and_return(false) + end + + it 'does not change the line items count' do + expect { distribution.copy_line_items(donation.id) }.not_to change { distribution.line_items.count } + end + end +end +describe '#copy_from_donation', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:donation) { create(:donation, :with_items, organization: organization, storage_location: storage_location) } + let(:distribution) { create(:distribution, organization: organization) } + + context 'when donation_id is provided and storage_location_id is provided' do + it 'copies line items from donation' do + distribution.copy_from_donation(donation.id, storage_location.id) + expect(distribution.line_items).to eq(donation.line_items) + end + + it 'sets storage location to the provided storage_location_id' do + distribution.copy_from_donation(donation.id, storage_location.id) + expect(distribution.storage_location).to eq(storage_location) + end + end + + context 'when donation_id is provided and storage_location_id is not provided' do + it 'copies line items from donation' do + distribution.copy_from_donation(donation.id, nil) + expect(distribution.line_items).to eq(donation.line_items) + end + + it 'does not set storage location' do + distribution.copy_from_donation(donation.id, nil) + expect(distribution.storage_location).to be_nil + end + end + + context 'when donation_id is not provided and storage_location_id is provided' do + it 'does not copy line items' do + distribution.copy_from_donation(nil, storage_location.id) + expect(distribution.line_items).to be_empty + end + + it 'sets storage location to the provided storage_location_id' do + distribution.copy_from_donation(nil, storage_location.id) + expect(distribution.storage_location).to eq(storage_location) + end + end + + context 'when donation_id is not provided and storage_location_id is not provided' do + it 'does not copy line items' do + distribution.copy_from_donation(nil, nil) + expect(distribution.line_items).to be_empty + end + + it 'does not set storage location' do + distribution.copy_from_donation(nil, nil) + expect(distribution.storage_location).to be_nil + end + end +end +describe "#initialize_request_items", :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, :with_items, organization: organization) } + let(:partner) { create(:partner) } + let(:distribution) { build(:distribution, storage_location: storage_location, partner: partner, organization: organization) } + + it "returns immediately if request is nil" do + distribution.request = nil + distribution.initialize_request_items + expect(distribution.line_items).to be_empty + end + + context "when line_items is empty" do + before do + distribution.line_items = [] + distribution.request = build(:request, item_requests: [build(:item_request)]) + distribution.initialize_request_items + end + + it "creates line_items from item_requests" do + expect(distribution.line_items).not_to be_empty + end + end + + context "when request.item_requests is empty" do + before do + distribution.request = build(:request, item_requests: []) + distribution.initialize_request_items + end + + it "does not create new line_items" do + expect(distribution.line_items).to be_empty + end + end + + context "when all line_items have corresponding item_requests" do + let(:item_request) { build(:item_request) } + + before do + distribution.line_items = [build(:line_item, item_id: item_request.item_id)] + distribution.request = build(:request, item_requests: [item_request]) + distribution.initialize_request_items + end + + it "assigns item_requests to line_items" do + expect(distribution.line_items.first.requested_item).to eq(item_request) + end + end + + context "when some item_requests do not have corresponding line_items" do + let(:item_request) { build(:item_request) } + + before do + distribution.line_items = [] + distribution.request = build(:request, item_requests: [item_request]) + distribution.initialize_request_items + end + + it "creates new line_items for those item_requests" do + expect(distribution.line_items.first.requested_item).to eq(item_request) + end + end + + context "when some line_items do not have corresponding item_requests" do + let(:item_request) { build(:item_request) } + + before do + distribution.line_items = [build(:line_item, item_id: 999)] + distribution.request = build(:request, item_requests: [item_request]) + distribution.initialize_request_items + end + + it "includes item_requests in line_items" do + expect(distribution.line_items.map(&:requested_item)).to include(item_request) + end + end +end +describe "#copy_from_request", :phoenix do + let(:organization) { create(:organization, :with_items) } + let(:partner) { create(:partner) } + let(:partner_user) { create(:partner_user) } + let(:distribution) { build(:distribution) } + + context "with a valid request" do + let(:request) { create(:request, organization: organization, partner: partner, partner_user: partner_user) } + + it "sets the request attribute" do + distribution.copy_from_request(request.id) + expect(distribution.request).to eq(request) + end + + it "sets the organization_id attribute" do + distribution.copy_from_request(request.id) + expect(distribution.organization_id).to eq(request.organization_id) + end + + it "sets the partner_id attribute" do + distribution.copy_from_request(request.id) + expect(distribution.partner_id).to eq(request.partner_id) + end + + it "sets the agency_rep attribute" do + distribution.copy_from_request(request.id) + expect(distribution.agency_rep).to eq(request.partner_user&.formatted_email) + end + + it "sets the comment attribute" do + distribution.copy_from_request(request.id) + expect(distribution.comment).to eq(request.comments) + end + + it "sets the issued_at attribute to tomorrow" do + distribution.copy_from_request(request.id) + expect(distribution.issued_at).to eq(Time.zone.today + 1.day) + end + end + + context "with a request with no item_requests" do + let(:request) { create(:request, organization: organization, partner: partner, partner_user: partner_user, item_requests: []) } + + it "does not create any line_items" do + distribution.copy_from_request(request.id) + expect(distribution.line_items).to be_empty + end + end + + describe "when item_requests have a request_unit" do + let(:item_request) { create(:item_request, item_id: 1, quantity: 10, request_unit: 'box') } + let(:request) { create(:request, organization: organization, partner: partner, partner_user: partner_user, item_requests: [item_request]) } + + it "does not prefill quantity for line_items" do + distribution.copy_from_request(request.id) + line_item = distribution.line_items.find_by(item_id: item_request.item_id) + expect(line_item.quantity).to be_nil + end + end + + describe "when item_requests do not have a request_unit" do + let(:item_request) { create(:item_request, item_id: 1, quantity: 10) } + let(:request) { create(:request, organization: organization, partner: partner, partner_user: partner_user, item_requests: [item_request]) } + + it "prefills quantity for line_items" do + distribution.copy_from_request(request.id) + line_item = distribution.line_items.find_by(item_id: item_request.item_id) + expect(line_item.quantity).to eq(item_request.quantity) + end + end + + context "with a non-existent request (invalid request_id)" do + it "raises an ActiveRecord::RecordNotFound error" do + expect { distribution.copy_from_request(-1) }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end +describe "#combine_distribution", :phoenix do + let(:empty_line_items) { [] } + let(:invalid_line_item) { build(:line_item, quantity: 1, item: build(:item, :inactive)) } + let(:zero_quantity_line_item) { build(:line_item, quantity: 0) } + let(:valid_line_item) { build(:line_item, quantity: 1, item: build(:item, :active)) } + let(:duplicate_line_item_1) { build(:line_item, quantity: 1, item: build(:item, :active, name: "Duplicate")) } + let(:duplicate_line_item_2) { build(:line_item, quantity: 2, item: build(:item, :active, name: "Duplicate")) } + let(:mixed_line_items) { [valid_line_item, invalid_line_item, zero_quantity_line_item] } + + it "does nothing when line_items is empty" do + distribution = Distribution.new(line_items: empty_line_items) + expect { distribution.combine_distribution }.not_to change { distribution.line_items } + end + + it "does nothing when line_items contains only invalid items" do + distribution = Distribution.new(line_items: [invalid_line_item]) + expect { distribution.combine_distribution }.not_to change { distribution.line_items } + end + + it "does nothing when line_items contains items with zero quantity" do + distribution = Distribution.new(line_items: [zero_quantity_line_item]) + expect { distribution.combine_distribution }.not_to change { distribution.line_items } + end + + describe "when line_items contains valid items" do + it "combines line_items with valid items and non-zero quantity" do + distribution = Distribution.new(line_items: [valid_line_item]) + expect { distribution.combine_distribution }.not_to change { distribution.line_items.size } + expect(distribution.line_items.first.quantity).to eq(1) + end + end + + describe "when line_items contains duplicate items" do + it "combines duplicate line_items with the same item_id" do + distribution = Distribution.new(line_items: [duplicate_line_item_1, duplicate_line_item_2]) + expect { distribution.combine_distribution }.to change { distribution.line_items.size }.from(2).to(1) + end + + it "updates the quantity of combined line_items" do + distribution = Distribution.new(line_items: [duplicate_line_item_1, duplicate_line_item_2]) + distribution.combine_distribution + expect(distribution.line_items.first.quantity).to eq(3) + end + end + + describe "when line_items contains a mix of valid and invalid items" do + it "removes invalid and zero quantity items" do + distribution = Distribution.new(line_items: mixed_line_items) + expect { distribution.combine_distribution }.to change { distribution.line_items.size }.from(3).to(1) + end + + it "keeps valid items with correct quantity" do + distribution = Distribution.new(line_items: mixed_line_items) + distribution.combine_distribution + expect(distribution.line_items.first.quantity).to eq(1) + end + end +end +describe "#csv_export_attributes", :phoenix do + let(:organization) { create(:organization) } + let(:partner) { create(:partner, organization: organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:distribution) { build(:distribution, partner: partner, storage_location: storage_location, organization: organization, issued_at: issued_at, delivery_method: delivery_method, state: state, agency_rep: agency_rep) } + let(:issued_at) { Time.current } + let(:delivery_method) { :pick_up } + let(:state) { :scheduled } + let(:agency_rep) { "John Doe" } + + it "returns correct attributes when all values are present" do + expect(distribution.csv_export_attributes).to eq([ + partner.name, + issued_at.strftime("%F"), + storage_location.name, + distribution.total_quantity, + distribution.cents_to_dollar(distribution.line_items.total_value), + delivery_method, + state, + agency_rep + ]) + end + + describe "when partner is missing" do + let(:partner) { nil } + + it "handles missing partner name" do + expect(distribution.csv_export_attributes.first).to be_nil + end + end + + describe "when issued_at is nil" do + let(:issued_at) { nil } + + it "handles missing issued_at date" do + expect(distribution.csv_export_attributes[1]).to be_nil + end + end + + describe "when storage_location is missing" do + let(:storage_location) { nil } + + it "handles missing storage location name" do + expect(distribution.csv_export_attributes[2]).to be_nil + end + end + + describe "when total_quantity is zero" do + before do + allow(distribution).to receive(:total_quantity).and_return(0) + end + + it "handles zero total quantity" do + expect(distribution.csv_export_attributes[3]).to eq(0) + end + end + + describe "when line_items total_value is zero" do + before do + allow(distribution.line_items).to receive(:total_value).and_return(0) + end + + it "handles zero total value" do + expect(distribution.csv_export_attributes[4]).to eq(distribution.cents_to_dollar(0)) + end + end + + describe "when delivery_method is nil" do + let(:delivery_method) { nil } + + it "handles missing delivery method" do + expect(distribution.csv_export_attributes[5]).to be_nil + end + end + + describe "when state is nil" do + let(:state) { nil } + + it "handles missing state" do + expect(distribution.csv_export_attributes[6]).to be_nil + end + end + + describe "when agency_rep is nil" do + let(:agency_rep) { nil } + + it "handles missing agency representative" do + expect(distribution.csv_export_attributes[7]).to be_nil + end + end +end +describe '#future?', :phoenix do + let(:future_distribution) { build(:distribution, issued_at: Time.zone.today + 1.day) } + let(:today_distribution) { build(:distribution, issued_at: Time.zone.today) } + let(:past_distribution) { build(:distribution, issued_at: Time.zone.today - 1.day) } + + it 'returns true when issued_at is in the future' do + expect(future_distribution.future?).to eq(true) + end + + it 'returns false when issued_at is today' do + expect(today_distribution.future?).to eq(false) + end + + it 'returns false when issued_at is in the past' do + expect(past_distribution.future?).to eq(false) + end +end +describe '#past?', :phoenix do + let(:distribution_past) { build(:distribution, :past) } + let(:distribution_today) { build(:distribution, issued_at: Time.zone.today) } + let(:distribution_future) { build(:distribution, issued_at: Time.zone.tomorrow) } + + it 'returns true if issued_at is before today' do + expect(distribution_past.past?).to be true + end + + it 'returns false if issued_at is today' do + expect(distribution_today.past?).to be false + end + + it 'returns false if issued_at is after today' do + expect(distribution_future.past?).to be false + end +end +describe '#line_items_quantity_is_positive', :phoenix do + let(:distribution_with_nil_storage) { build(:distribution, storage_location: nil) } + let(:distribution_with_nil_quantity) { build(:distribution, :with_items, item_quantity: nil) } + let(:distribution_with_low_quantity) { build(:distribution, :with_items, item_quantity: 0) } + let(:distribution_with_valid_quantity) { build(:distribution, :with_items, item_quantity: 1) } + + it 'does nothing if storage_location is nil' do + distribution_with_nil_storage.line_items_quantity_is_positive + expect(distribution_with_nil_storage.errors[:base]).to be_empty + end + + context 'when line item quantity is nil' do + it 'adds an error for line item with nil quantity' do + distribution_with_nil_quantity.line_items_quantity_is_positive + expect(distribution_with_nil_quantity.errors[:line_items]).to include('quantity must be at least 1') + end + end + + context 'when line item quantity is less than 1' do + it 'adds an error for line item with quantity less than 1' do + distribution_with_low_quantity.line_items_quantity_is_positive + expect(distribution_with_low_quantity.errors[:line_items]).to include('quantity must be at least 1') + end + end + + context 'when line item quantity is 1 or more' do + it 'does not add an error for line item with quantity 1 or more' do + distribution_with_valid_quantity.line_items_quantity_is_positive + expect(distribution_with_valid_quantity.errors[:line_items]).to be_empty + end + end +end +describe "#reset_shipping_cost", :phoenix do + let(:distribution_shipped) { build(:distribution, delivery_method: 'shipped', shipping_cost: 10.0) } + let(:distribution_not_shipped) { build(:distribution, delivery_method: 'pick_up', shipping_cost: 10.0) } + + it "does not reset shipping_cost when delivery_method is 'shipped'" do + distribution_shipped.reset_shipping_cost + expect(distribution_shipped.shipping_cost).to eq(10.0) + end + + describe "when delivery_method is not 'shipped'" do + it "resets shipping_cost to nil" do + distribution_not_shipped.reset_shipping_cost + expect(distribution_not_shipped.shipping_cost).to be_nil + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/donation_site_spec.rb b/phoenix-tests/unit/tests/app/models/donation_site_spec.rb new file mode 100644 index 0000000000..0508af1a4a --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/donation_site_spec.rb @@ -0,0 +1,97 @@ + +require "rails_helper" + +RSpec.describe DonationSite do +describe '#import_csv', :phoenix do + let(:organization) { create(:organization) } + let(:valid_csv) { [ { 'name' => 'Site 1', 'address' => '123 Main St', 'contact_name' => 'John Doe', 'email' => 'john@example.com', 'phone' => '555-1234' }, { 'name' => 'Site 2', 'address' => '456 Elm St', 'contact_name' => 'Jane Doe', 'email' => 'jane@example.com', 'phone' => '555-5678' } ] } + let(:invalid_csv) { [ { 'name' => '', 'address' => '', 'contact_name' => '', 'email' => '', 'phone' => '' } ] } + let(:empty_csv) { [] } + + it 'increases DonationSite count by 2 when valid CSV is imported' do + expect { DonationSite.import_csv(valid_csv, organization.id) }.to change { DonationSite.count }.by(2) + end + + it 'raises an error when importing invalid CSV data' do + expect { DonationSite.import_csv(invalid_csv, organization.id) }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'does not change DonationSite count when CSV is empty' do + expect { DonationSite.import_csv(empty_csv, organization.id) }.not_to change { DonationSite.count } + end +end +describe ".csv_export_headers", :phoenix do + it "returns an array with 'Name' as the first header" do + expect(DonationSite.csv_export_headers.first).to eq("Name") + end + + it "returns an array with 'Address' as the second header" do + expect(DonationSite.csv_export_headers[1]).to eq("Address") + end + + it "returns an array with 'Contact Name' as the third header" do + expect(DonationSite.csv_export_headers[2]).to eq("Contact Name") + end + + it "returns an array with 'Email' as the fourth header" do + expect(DonationSite.csv_export_headers[3]).to eq("Email") + end + + it "returns an array with 'Phone' as the fifth header" do + expect(DonationSite.csv_export_headers[4]).to eq("Phone") + end + + it "returns an array with exactly five headers" do + expect(DonationSite.csv_export_headers.size).to eq(5) + end +end +describe '#csv_export_attributes', :phoenix do + let(:donation_site) { build(:donation_site, name: 'Charity Hub', address: '123 Charity Lane', contact_name: 'John Doe', email: 'contact@charityhub.org', phone: '123-456-7890') } + + it 'returns an array of all attributes' do + expect(donation_site.csv_export_attributes).to eq(['Charity Hub', '123 Charity Lane', 'John Doe', 'contact@charityhub.org', '123-456-7890']) + end + + context 'when contact_name is blank' do + let(:donation_site) { build(:donation_site, contact_name: '') } + + it 'returns an array with blank contact_name' do + expect(donation_site.csv_export_attributes).to eq(['Charity Hub', '123 Charity Lane', '', 'contact@charityhub.org', '123-456-7890']) + end + end + + context 'when email is blank' do + let(:donation_site) { build(:donation_site, email: '') } + + it 'returns an array with blank email' do + expect(donation_site.csv_export_attributes).to eq(['Charity Hub', '123 Charity Lane', 'John Doe', '', '123-456-7890']) + end + end + + context 'when phone is blank' do + let(:donation_site) { build(:donation_site, phone: '') } + + it 'returns an array with blank phone' do + expect(donation_site.csv_export_attributes).to eq(['Charity Hub', '123 Charity Lane', 'John Doe', 'contact@charityhub.org', '']) + end + end +end +describe '#deactivate!', :phoenix do + let(:donation_site) { create(:donation_site, active: true) } + + it 'deactivates the donation site by setting active to false' do + donation_site.deactivate! + expect(donation_site.active).to be_falsey + end + + context 'when update fails' do + before do + allow(donation_site).to receive(:update!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises ActiveRecord::RecordInvalid error' do + expect { donation_site.deactivate! }.to raise_error(ActiveRecord::RecordInvalid) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/donation_spec.rb b/phoenix-tests/unit/tests/app/models/donation_spec.rb new file mode 100644 index 0000000000..91b6b33d91 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/donation_spec.rb @@ -0,0 +1,390 @@ + +require "rails_helper" + +RSpec.describe Donation do +describe '#from_product_drive?', :phoenix do + let(:product_drive_donation) { build(:product_drive_donation) } + let(:misc_donation) { build(:donation) } + + it 'returns true when the donation source is product drive' do + donation = product_drive_donation + expect(donation.from_product_drive?).to be true + end + + it 'returns false when the donation source is not product drive' do + donation = misc_donation + expect(donation.from_product_drive?).to be false + end +end +describe '#from_manufacturer?', :phoenix do + let(:manufacturer_donation) { build(:manufacturer_donation) } + let(:non_manufacturer_donation) { build(:donation) } + + it 'returns true when the donation source is manufacturer' do + donation = manufacturer_donation + expect(donation.from_manufacturer?).to be true + end + + it 'returns false when the donation source is not manufacturer' do + donation = non_manufacturer_donation + expect(donation.from_manufacturer?).to be false + end +end +describe '#from_donation_site?', :phoenix do + let(:donation_site_donation) { build(:donation, source: Donation::SOURCES[:donation_site]) } + let(:other_source_donation) { build(:donation, source: Donation::SOURCES[:misc]) } + + it 'returns true when the source is donation_site' do + expect(donation_site_donation.from_donation_site?).to eq(true) + end + + it 'returns false when the source is not donation_site' do + expect(other_source_donation.from_donation_site?).to eq(false) + end +end +describe "#source_view", :phoenix do + let(:organization) { create(:organization) } + let(:product_drive) { build(:product_drive, organization: organization) } + let(:product_drive_participant) { build(:product_drive_participant, organization: organization) } + + context "when not from product drive" do + let(:donation) { build(:donation, source: 'some_source', product_drive: nil, product_drive_participant: nil) } + + it "returns the source when not from product drive" do + expect(donation.source_view).to eq('some_source') + end + end + + context "when from product drive" do + let(:donation) { build(:product_drive_donation, product_drive: product_drive, product_drive_participant: product_drive_participant) } + + context "when product_drive_participant's donation_source_view is present" do + before do + allow(product_drive_participant).to receive(:donation_source_view).and_return('participant_view') + end + + it "returns product_drive_participant's donation_source_view" do + expect(donation.source_view).to eq('participant_view') + end + end + + context "when product_drive_participant's donation_source_view is nil" do + before do + allow(product_drive_participant).to receive(:donation_source_view).and_return(nil) + allow(product_drive).to receive(:donation_source_view).and_return('drive_view') + end + + it "returns product_drive's donation_source_view" do + expect(donation.source_view).to eq('drive_view') + end + end + end +end +describe '#daily_quantities_by_source', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:item) { create(:item) } + + let(:donation_with_items) do + create(:donation, :with_items, organization: organization, storage_location: storage_location, item: item, item_quantity: 50) + end + + let(:donation_without_items) do + create(:donation, organization: organization, storage_location: storage_location) + end + + let(:start_date) { Date.today.beginning_of_month } + let(:stop_date) { Date.today.end_of_month } + + it 'returns correct quantities for a given date range and source' do + donation_with_items + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to eq({ donation_with_items.source => { Date.today => 50 } }) + end + + describe 'when there are no donations in the given date range' do + it 'returns an empty result' do + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to be_empty + end + end + + describe 'when there are multiple sources' do + let(:another_donation_with_items) do + create(:manufacturer_donation, :with_items, organization: organization, storage_location: storage_location, item: item, item_quantity: 30) + end + + it 'groups quantities by each source' do + donation_with_items + another_donation_with_items + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to eq({ + donation_with_items.source => { Date.today => 50 }, + another_donation_with_items.source => { Date.today => 30 } + }) + end + end + + describe 'when donations have no line items' do + it 'returns zero quantities' do + donation_without_items + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to eq({ donation_without_items.source => { Date.today => 0 } }) + end + end + + describe 'when start date is after stop date' do + let(:start_date) { Date.today.end_of_month } + let(:stop_date) { Date.today.beginning_of_month } + + it 'handles invalid date range gracefully' do + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to be_empty + end + end + + describe 'when there are donations on the boundary dates' do + let(:boundary_donation_start) do + create(:donation, :with_items, organization: organization, storage_location: storage_location, item: item, item_quantity: 20, issued_at: start_date) + end + + let(:boundary_donation_stop) do + create(:donation, :with_items, organization: organization, storage_location: storage_location, item: item, item_quantity: 20, issued_at: stop_date) + end + + it 'includes donations on the start date' do + boundary_donation_start + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to include(boundary_donation_start.source => { start_date => 20 }) + end + + it 'includes donations on the stop date' do + boundary_donation_stop + result = Donation.daily_quantities_by_source(start_date, stop_date) + expect(result).to include(boundary_donation_stop.source => { stop_date => 20 }) + end + end +end +describe '#details', :phoenix do + let(:product_drive) { build(:product_drive, name: 'Test Drive') } + let(:manufacturer) { build(:manufacturer, name: 'Test Manufacturer') } + let(:donation_site) { build(:donation_site, name: 'Test Donation Site') } + let(:comment) { 'This is a test comment that is quite long and needs truncation.' } + + context 'when source is product_drive' do + let(:donation) { build(:product_drive_donation, product_drive: product_drive) } + + it 'returns the product drive name' do + expect(donation.details).to eq('Test Drive') + end + end + + context 'when source is manufacturer' do + let(:donation) { build(:manufacturer_donation, manufacturer: manufacturer) } + + it 'returns the manufacturer name' do + expect(donation.details).to eq('Test Manufacturer') + end + end + + context 'when source is donation_site' do + let(:donation) { build(:donation_site_donation, donation_site: donation_site) } + + it 'returns the donation site name' do + expect(donation.details).to eq('Test Donation Site') + end + end + + context 'when source is misc' do + let(:donation) { build(:donation, source: Donation::SOURCES[:misc], comment: comment) } + + it 'returns the truncated comment' do + expect(donation.details).to eq('This is a test comment that is...') + end + + context 'with nil comment' do + let(:donation) { build(:donation, source: Donation::SOURCES[:misc], comment: nil) } + + it 'handles nil comment gracefully' do + expect(donation.details).to be_nil + end + end + end +end +describe '#remove', :phoenix do + let(:donation) { create(:donation, :with_items) } + let(:line_item) { donation.line_items.first } + let(:non_existent_id) { line_item.id + 1 } + let(:non_convertible_item) { 'non-integer' } + + it 'removes the line item when item is an ID and line item is found' do + expect { donation.remove(line_item.id) }.to change { donation.line_items.count }.by(-1) + end + + it 'does nothing when item is an ID and line item is not found' do + expect { donation.remove(non_existent_id) }.not_to change { donation.line_items.count } + end + + it 'removes the line item when item is an object and line item is found' do + expect { donation.remove(line_item) }.to change { donation.line_items.count }.by(-1) + end + + it 'does nothing when item is an object and line item is not found' do + non_existent_item = build(:line_item, id: non_existent_id) + expect { donation.remove(non_existent_item) }.not_to change { donation.line_items.count } + end + + it 'does nothing when item is not convertible to an integer' do + expect { donation.remove(non_convertible_item) }.not_to change { donation.line_items.count } + end +end +describe "#money_raised_in_dollars", :phoenix do + let(:positive_donation) { build(:donation, money_raised: 10000) } + let(:zero_donation) { build(:donation, money_raised: 0) } + let(:negative_donation) { build(:donation, money_raised: -5000) } + let(:non_integer_donation) { build(:donation, money_raised: 1234.56) } + + it "converts positive money_raised to dollars" do + expect(positive_donation.money_raised_in_dollars).to eq(100.0) + end + + it "converts zero money_raised to dollars" do + expect(zero_donation.money_raised_in_dollars).to eq(0.0) + end + + it "converts negative money_raised to dollars" do + expect(negative_donation.money_raised_in_dollars).to eq(-50.0) + end + + it "handles non-integer money_raised values" do + expect(non_integer_donation.money_raised_in_dollars).to eq(12.3456) + end +end +describe "#donation_site_view", :phoenix do + let(:donation_site) { build(:donation_site) } + let(:donation_with_site) { build(:donation, donation_site: donation_site) } + let(:donation_without_site) { build(:donation, donation_site: nil) } + + it "returns 'N/A' when donation_site is nil" do + donation = donation_without_site + expect(donation.donation_site_view).to eq("N/A") + end + + it "returns the name of the donation_site when it is not nil" do + donation = donation_with_site + expect(donation.donation_site_view).to eq(donation_site.name) + end +end +describe "#storage_view", :phoenix do + let(:storage_location) { build(:storage_location, name: "Main Warehouse") } + let(:donation_with_location) { build(:donation, storage_location: storage_location) } + let(:donation_without_location) { build(:donation, storage_location: nil) } + + it "returns 'N/A' when storage_location is nil" do + expect(donation_without_location.storage_view).to eq("N/A") + end + + it "returns the name of the storage_location when it is not nil" do + expect(donation_with_location.storage_view).to eq("Main Warehouse") + end +end +describe '#in_kind_value_money', :phoenix do + let(:donation) { build(:donation, value_per_itemizable: value_per_itemizable) } + + context 'when value_per_itemizable is a valid numeric value' do + let(:value_per_itemizable) { 1000 } + + it 'returns a Money object with the correct amount' do + expect(donation.in_kind_value_money).to eq(Money.new(1000)) + end + end + + context 'when value_per_itemizable is nil' do + let(:value_per_itemizable) { nil } + + it 'raises an ArgumentError' do + expect { donation.in_kind_value_money }.to raise_error(ArgumentError) + end + end + + context 'when value_per_itemizable is 0' do + let(:value_per_itemizable) { 0 } + + it 'returns a Money object with zero amount' do + expect(donation.in_kind_value_money).to eq(Money.new(0)) + end + end + + context 'when value_per_itemizable is negative' do + let(:value_per_itemizable) { -1000 } + + it 'returns a Money object with the negative amount' do + expect(donation.in_kind_value_money).to eq(Money.new(-1000)) + end + end + + context 'when value_per_itemizable is non-numeric' do + let(:value_per_itemizable) { 'non-numeric' } + + it 'raises an ArgumentError' do + expect { donation.in_kind_value_money }.to raise_error(ArgumentError) + end + end + + it 'raises a StandardError for invalid Money initialization' do + allow(Money).to receive(:new).and_raise(StandardError) + expect { donation.in_kind_value_money }.to raise_error(StandardError) + end +end +describe '#combine_duplicates', :phoenix do + let(:donation) { build(:donation) } + let(:item) { create(:item) } + + context 'when there are no line items' do + it 'does not change the line items count' do + expect { donation.combine_duplicates }.not_to change { donation.line_items.count } + end + end + + context 'when there are line items with zero quantity' do + let!(:line_item_zero_quantity) { build(:line_item, quantity: 0, item: item, itemizable: donation) } + + it 'does not change the line items count' do + expect { donation.combine_duplicates }.not_to change { donation.line_items.count } + end + end + + context 'when there are line items with the same item_id' do + let!(:line_item1) { build(:line_item, quantity: 1, item: item, itemizable: donation) } + let!(:line_item2) { build(:line_item, quantity: 2, item: item, itemizable: donation) } + + it 'reduces the line items count to 1' do + donation.combine_duplicates + expect(donation.line_items.count).to eq(1) + end + + it 'sets the combined line item quantity correctly' do + donation.combine_duplicates + expect(donation.line_items.first.quantity).to eq(3) + end + end + + context 'when there are line items with different item_ids' do + let(:different_item) { create(:item) } + let!(:line_item1) { build(:line_item, quantity: 1, item: item, itemizable: donation) } + let!(:line_item2) { build(:line_item, quantity: 2, item: different_item, itemizable: donation) } + + it 'does not change the line items count' do + expect { donation.combine_duplicates }.not_to change { donation.line_items.count } + end + end + + context 'when there are invalid line items' do + let!(:invalid_line_item) { build(:line_item, quantity: -1, item: item, itemizable: donation) } + + it 'does not change the line items count' do + expect { donation.combine_duplicates }.not_to change { donation.line_items.count } + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/errors_spec.rb b/phoenix-tests/unit/tests/app/models/errors_spec.rb new file mode 100644 index 0000000000..fd470f43c9 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/errors_spec.rb @@ -0,0 +1,189 @@ + +require "rails_helper" + +RSpec.describe Errors::InsufficientAllotment do +describe '#initialize', :phoenix do + let(:message) { 'Test message' } + let(:insufficient_items) { [] } + + it 'sets @insufficient_items to an empty array when only a message is provided' do + error = Errors::InsufficientAllotment.new(message) + expect(error.instance_variable_get(:@insufficient_items)).to eq([]) + end + + it 'sets @insufficient_items to an empty array when an empty array is provided' do + error = Errors::InsufficientAllotment.new(message, insufficient_items) + expect(error.instance_variable_get(:@insufficient_items)).to eq([]) + end + + describe 'when initialized with a non-empty array' do + let(:insufficient_items) { ['item1', 'item2'] } + + it 'sets @insufficient_items to the provided non-empty array' do + error = Errors::InsufficientAllotment.new(message, insufficient_items) + expect(error.instance_variable_get(:@insufficient_items)).to eq(['item1', 'item2']) + end + end +end +describe '#add_insufficiency', :phoenix do + let(:item) { build(:item, id: 1, name: 'Test Item') } + let(:quantity_on_hand) { 10 } + let(:quantity_requested) { 5 } + let(:insufficient_allotment) { Errors::InsufficientAllotment.new } + + it 'adds an item with correct attributes to insufficient_items' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items).to include( + item_id: item.id, + item: item.name, + quantity_on_hand: quantity_on_hand, + quantity_requested: quantity_requested + ) + end + + describe 'when quantity_on_hand and quantity_requested are strings' do + let(:quantity_on_hand) { '10' } + let(:quantity_requested) { '5' } + + it 'converts quantity_on_hand to integer' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:quantity_on_hand]).to eq(10) + end + + it 'converts quantity_requested to integer' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:quantity_requested]).to eq(5) + end + end + + describe 'when quantity_on_hand is zero' do + let(:quantity_on_hand) { 0 } + + it 'handles zero quantity on hand' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:quantity_on_hand]).to eq(0) + end + end + + describe 'when quantity_requested is zero' do + let(:quantity_requested) { 0 } + + it 'handles zero quantity requested' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:quantity_requested]).to eq(0) + end + end + + describe 'when quantity_on_hand is negative' do + let(:quantity_on_hand) { -5 } + + it 'handles negative quantity on hand' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:quantity_on_hand]).to eq(-5) + end + end + + describe 'when quantity_requested is negative' do + let(:quantity_requested) { -5 } + + it 'handles negative quantity requested' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:quantity_requested]).to eq(-5) + end + end + + it 'extracts item_id correctly from item object' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:item_id]).to eq(item.id) + end + + it 'extracts item name correctly from item object' do + insufficient_allotment.add_insufficiency(item, quantity_on_hand, quantity_requested) + expect(insufficient_allotment.insufficient_items.last[:item]).to eq(item.name) + end +end +describe '#satisfied?', :phoenix do + let(:insufficient_items) { [] } + let(:insufficient_allotment) { Errors::InsufficientAllotment.new(insufficient_items: insufficient_items) } + + it 'returns true when there are no insufficient items' do + expect(insufficient_allotment.satisfied?).to be true + end + + context 'when there are insufficient items' do + let(:insufficient_items) { [build(:item)] } + + it 'returns false when there are insufficient items' do + expect(insufficient_allotment.satisfied?).to be false + end + end +end +describe '#message', :phoenix do + let(:insufficient_items) { [] } + let(:insufficient_allotment) { Errors::InsufficientAllotment.new(insufficient_items: insufficient_items) } + + it 'returns base message when insufficient_items is empty' do + expect(insufficient_allotment.message).to eq('Base message') + end + + describe 'when insufficient_items contains one item' do + context 'with quantity_requested greater than quantity_on_hand' do + let(:insufficient_items) do + [ + { quantity_requested: 5, item_name: 'Widget', quantity_on_hand: 3 } + ] + end + + it 'returns message for the item with excess request' do + expect(insufficient_allotment.message).to eq('Base message 5 Widget requested, only 3 available. (Reduce by 2)') + end + end + + context 'with quantity_requested equal to quantity_on_hand' do + let(:insufficient_items) do + [ + { quantity_requested: 3, item_name: 'Widget', quantity_on_hand: 3 } + ] + end + + it 'returns message for the item with exact request' do + expect(insufficient_allotment.message).to eq('Base message 3 Widget requested, only 3 available. (Reduce by 0)') + end + end + end + + describe 'when insufficient_items contains multiple items' do + let(:insufficient_items) do + [ + { quantity_requested: 5, item_name: 'Widget', quantity_on_hand: 3 }, + { quantity_requested: 10, item_name: 'Gadget', quantity_on_hand: 8 } + ] + end + + it 'returns message for first item with excess request' do + expect(insufficient_allotment.message).to include('5 Widget requested, only 3 available. (Reduce by 2)') + end + + it 'returns message for second item with excess request' do + expect(insufficient_allotment.message).to include('10 Gadget requested, only 8 available. (Reduce by 2)') + end + end +end +describe '#message', :phoenix do + let(:insufficient_allotment_error) { Errors::InsufficientAllotment.new } + + it 'returns the correct error message' do + expect(insufficient_allotment_error.message).to eq("Storage location kit doesn't match") + end +end +describe '#message', :phoenix do + it 'returns the correct error message for missing kit allocation' do + expect(Errors::InsufficientAllotment.new.message).to eq('KitAllocation not found for given kit') + end +end +describe '#message', :phoenix do + it 'returns the correct message when inventory has items stored' do + expect(subject.message).to eq('Could not complete action: inventory already has items stored') + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/event_spec.rb b/phoenix-tests/unit/tests/app/models/event_spec.rb new file mode 100644 index 0000000000..803fb149ce --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/event_spec.rb @@ -0,0 +1,147 @@ + +require "rails_helper" + +RSpec.describe Event do +describe "#types_for_select", :phoenix do + let(:descendant_class_1) { Class.new(Event) { def self.name; 'MusicEvent'; end } } + let(:descendant_class_2) { Class.new(Event) { def self.name; 'ArtEvent'; end } } + let(:unexpected_class) { Class.new(Event) { def self.name; 'UnexpectedPattern'; end } } + + before do + stub_const('MusicEvent', descendant_class_1) + stub_const('ArtEvent', descendant_class_2) + stub_const('UnexpectedPattern', unexpected_class) + end + + it "returns an array of OpenStruct objects with correct size" do + result = Event.types_for_select + expect(result.size).to eq(2) + end + + it "contains correct class values" do + result = Event.types_for_select + expect(result.map(&:value)).to contain_exactly('MusicEvent', 'ArtEvent') + end + + it "removes 'Event' from class name and titleizes it for the name attribute" do + result = Event.types_for_select + expect(result.map(&:name)).to contain_exactly('Music', 'Art') + end + + it "sorts the OpenStruct objects by name attribute" do + result = Event.types_for_select + expect(result.map(&:name)).to eq(['Art', 'Music']) + end + + context "when there are no descendants" do + before do + allow(Event).to receive(:descendants).and_return([]) + end + + it "returns an empty array" do + result = Event.types_for_select + expect(result).to eq([]) + end + end + + context "when descendant class names do not follow the expected pattern" do + before do + allow(Event).to receive(:descendants).and_return([unexpected_class]) + end + + it "handles unexpected class name patterns gracefully" do + result = Event.types_for_select + expect(result.map(&:name)).to contain_exactly('Unexpected Pattern') + end + end +end +describe '#most_recent_snapshot', :phoenix do + let(:organization_id) { 1 } + + let!(:snapshot_event) { Event.create(type: 'SnapshotEvent', organization_id: organization_id, event_time: 2.days.ago, updated_at: 2.days.ago) } + let!(:non_snapshot_event) { Event.create(type: 'NonSnapshotEvent', organization_id: organization_id, event_time: 3.days.ago, updated_at: 1.day.ago) } + + it 'retrieves the most recent snapshot event when it exists' do + result = Event.most_recent_snapshot(organization_id) + expect(result).to eq(snapshot_event) + end + + it 'returns nil when there are no snapshot events for the organization' do + Event.where(type: 'SnapshotEvent', organization_id: organization_id).destroy_all + result = Event.most_recent_snapshot(organization_id) + expect(result).to be_nil + end + + it 'returns the most recent snapshot event when non-snapshot events have earlier event times but later update times' do + result = Event.most_recent_snapshot(organization_id) + expect(result).to eq(snapshot_event) + end + + it 'returns the most recent snapshot event when multiple snapshot events exist' do + recent_snapshot_event = Event.create(type: 'SnapshotEvent', organization_id: organization_id, event_time: 1.day.ago, updated_at: 1.day.ago) + result = Event.most_recent_snapshot(organization_id) + expect(result).to eq(recent_snapshot_event) + end + + it 'returns one of the snapshot events when multiple have the same event time' do + another_snapshot_event = Event.create(type: 'SnapshotEvent', organization_id: organization_id, event_time: 2.days.ago, updated_at: 2.days.ago) + result = Event.most_recent_snapshot(organization_id) + expect([snapshot_event, another_snapshot_event]).to include(result) + end +end +describe '#validate_inventory', :phoenix do + let(:organization) { create(:organization) } + let(:item) { create(:item, organization: organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:event) { Event.create(organization: organization) } + + it 'handles successful inventory validation' do + allow(InventoryAggregate).to receive(:inventory_for).and_return(true) + expect { event.validate_inventory }.not_to raise_error + end + + context 'when InventoryError is raised' do + let(:inventory_error) { InventoryError.new('Error message', item_id: item.id, storage_location_id: storage_location.id, event: event) } + + before do + allow(InventoryAggregate).to receive(:inventory_for).and_raise(inventory_error) + end + + it 'raises error with item name when item is found' do + expect { event.validate_inventory }.to raise_error(InventoryError, /for #{item.name}/) + end + + it 'raises error with item ID when item is not found' do + inventory_error.item_id = nil + expect { event.validate_inventory }.to raise_error(InventoryError, /for Item ID/) + end + + it 'raises error with storage location name when storage location is found' do + expect { event.validate_inventory }.to raise_error(InventoryError, /in #{storage_location.name}/) + end + + it 'raises error with storage location ID when storage location is not found' do + inventory_error.storage_location_id = nil + expect { event.validate_inventory }.to raise_error(InventoryError, /in Storage Location ID/) + end + + context 'when error event matches current event' do + it 'raises error with original message' do + expect { event.validate_inventory }.to raise_error(InventoryError, /Error message/) + end + end + + context 'when error event does not match current event' do + let(:other_event) { Event.create(organization: organization) } + + before do + inventory_error.event = other_event + end + + it 'raises error with re-run event message' do + expect { event.validate_inventory }.to raise_error(InventoryError, /Error occurred when re-running events/) + end + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/inventory_item_spec.rb b/phoenix-tests/unit/tests/app/models/inventory_item_spec.rb new file mode 100644 index 0000000000..b829221c99 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/inventory_item_spec.rb @@ -0,0 +1,53 @@ + +require "rails_helper" + +RSpec.describe InventoryItem do +describe '#to_h', :phoenix do + let(:item) { build(:item, name: item_name) } + let(:inventory_item) { build(:inventory_item, item: item, item_id: item_id, quantity: quantity) } + let(:item_id) { 1 } + let(:quantity) { 10 } + let(:item_name) { 'Sample Item' } + + it 'converts item to hash with stringified keys' do + expected_hash = {'item_id' => '1', 'quantity' => '10', 'item_name' => 'Sample Item'} + expect(inventory_item.to_h).to eq(expected_hash) + end + + describe 'when item_id is nil' do + let(:item_id) { nil } + + it 'handles nil item_id' do + expected_hash = {'item_id' => nil, 'quantity' => '10', 'item_name' => 'Sample Item'} + expect(inventory_item.to_h).to eq(expected_hash) + end + end + + describe 'when quantity is nil' do + let(:quantity) { nil } + + it 'handles nil quantity' do + expected_hash = {'item_id' => '1', 'quantity' => nil, 'item_name' => 'Sample Item'} + expect(inventory_item.to_h).to eq(expected_hash) + end + end + + describe 'when item is nil' do + let(:item) { nil } + + it 'handles nil item' do + expected_hash = {'item_id' => '1', 'quantity' => '10', 'item_name' => nil} + expect(inventory_item.to_h).to eq(expected_hash) + end + end + + describe 'when item.name is nil' do + let(:item_name) { nil } + + it 'handles nil item name' do + expected_hash = {'item_id' => '1', 'quantity' => '10', 'item_name' => nil} + expect(inventory_item.to_h).to eq(expected_hash) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/item_category_spec.rb b/phoenix-tests/unit/tests/app/models/item_category_spec.rb new file mode 100644 index 0000000000..27828ef797 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/item_category_spec.rb @@ -0,0 +1,6 @@ + +require "rails_helper" + +RSpec.describe ItemCategory do + +end diff --git a/phoenix-tests/unit/tests/app/models/item_spec.rb b/phoenix-tests/unit/tests/app/models/item_spec.rb new file mode 100644 index 0000000000..ec925125ce --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/item_spec.rb @@ -0,0 +1,755 @@ + +require "rails_helper" + +RSpec.describe Item do +describe '.barcoded_items', :phoenix do + let(:organization) { create(:organization) } + let(:item_category) { create(:item_category, organization: organization) } + + it 'returns empty when there are no items' do + expect(Item.barcoded_items).to be_empty + end + + context 'when there is a single item with no barcode items' do + let!(:item) { create(:item, organization: organization, item_category: item_category) } + + it 'returns empty' do + expect(Item.barcoded_items).to be_empty + end + end + + context 'when there are multiple items with no barcode items' do + let!(:items) { create_list(:item, 3, organization: organization, item_category: item_category) } + + it 'returns empty' do + expect(Item.barcoded_items).to be_empty + end + end + + context 'when there is a single item with multiple barcode items' do + let!(:item) { create(:item, organization: organization, item_category: item_category) } + let!(:barcode_items) { create_list(:barcode_item, 3, item: item) } + + it 'returns the item' do + expect(Item.barcoded_items).to contain_exactly(item) + end + end + + context 'when there are multiple items each with multiple barcode items' do + let!(:items) { create_list(:item, 3, organization: organization, item_category: item_category) } + before do + items.each { |item| create_list(:barcode_item, 2, item: item) } + end + + it 'returns all items' do + expect(Item.barcoded_items).to match_array(items) + end + end + + context 'when items have duplicate names' do + let!(:item1) { create(:item, name: 'Duplicate', organization: organization, item_category: item_category) } + let!(:item2) { create(:item, name: 'Duplicate', organization: organization, item_category: item_category) } + + it 'returns empty' do + expect(Item.barcoded_items).to be_empty + end + end + + context 'when items have case-sensitive names' do + let!(:item1) { create(:item, name: 'CaseSensitive', organization: organization, item_category: item_category) } + let!(:item2) { create(:item, name: 'casesensitive', organization: organization, item_category: item_category) } + + it 'returns empty' do + expect(Item.barcoded_items).to be_empty + end + end + + context 'when items have special characters in names' do + let!(:item) { create(:item, name: 'Special!@#$', organization: organization, item_category: item_category) } + + it 'returns empty' do + expect(Item.barcoded_items).to be_empty + end + end + + context 'when items have null or blank names' do + let!(:item) { create(:item, name: nil, organization: organization, item_category: item_category) } + + it 'returns empty' do + expect(Item.barcoded_items).to be_empty + end + end + + context 'performance with large data sets' do + let!(:items) { create_list(:item, 1000, organization: organization, item_category: item_category) } + + it 'does not raise error' do + expect { Item.barcoded_items }.not_to raise_error + end + end + + context 'database constraints' do + it 'does not raise error' do + expect { Item.barcoded_items }.not_to raise_error + end + end + + context 'concurrency' do + it 'does not raise error' do + expect { Item.barcoded_items }.not_to raise_error + end + end +end +describe '.barcodes_for', :phoenix do + let(:organization) { Organization.try(:first) || create(:organization) } + let(:item) { build(:item, organization: organization) } + let(:barcode_item) { build(:barcode_item, barcodeable: item, organization: organization) } + + context 'when there are matching barcodes' do + before do + barcode_item.save + end + + it 'returns the matching barcodes' do + result = Item.barcodes_for(item) + expect(result).to include(barcode_item) + end + end + + context 'when there are no matching barcodes' do + it 'returns an empty collection' do + result = Item.barcodes_for(item) + expect(result).to be_empty + end + end + + context 'when the item is nil' do + let(:item) { nil } + + it 'raises a NoMethodError' do + expect { Item.barcodes_for(item) }.to raise_error(NoMethodError) + end + end + + context 'when the item is invalid' do + let(:item) { build(:item, id: nil) } + + it 'raises a RecordNotFound error' do + expect { Item.barcodes_for(item) }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end +describe '.reactivate', :phoenix do + let(:active_item) { create(:item, active: true) } + let(:inactive_item) { create(:item, active: false) } + let(:another_inactive_item) { create(:item, active: false) } + + it 'reactivates a single inactive item' do + Item.reactivate(inactive_item.id) + expect(inactive_item.reload.active).to eq(true) + end + + describe 'when multiple items are reactivated' do + it 'reactivates all specified inactive items' do + Item.reactivate([inactive_item.id, another_inactive_item.id]) + expect(inactive_item.reload.active).to eq(true) + expect(another_inactive_item.reload.active).to eq(true) + end + end + + describe 'when given an empty array' do + it 'does not change the active status of any items' do + expect { Item.reactivate([]) }.not_to change { Item.where(active: true).count } + end + end + + describe 'when given nil input' do + it 'does not change the active status of any items' do + expect { Item.reactivate(nil) }.not_to change { Item.where(active: true).count } + end + end + + describe 'when given a mix of valid and invalid item IDs' do + it 'reactivates only the valid inactive items' do + Item.reactivate([inactive_item.id, 9999]) # assuming 9999 is an invalid ID + expect(inactive_item.reload.active).to eq(true) + end + end + + describe 'when all item IDs are invalid' do + it 'does not change the active status of any items' do + expect { Item.reactivate([9999, 8888]) }.not_to change { Item.where(active: true).count } + end + end +end +describe "#has_inventory?", :phoenix do + let(:item) { build(:item) } + + it "returns false when inventory is nil" do + inventory = nil + expect(item.has_inventory?(inventory)).to be_falsey + end + + context "when inventory is not nil" do + let(:inventory) { double("Inventory") } + + before do + allow(inventory).to receive(:quantity_for).with(item_id: item.id).and_return(quantity) + end + + context "with positive quantity" do + let(:quantity) { 5 } + it "returns true for positive quantity" do + expect(item.has_inventory?(inventory)).to be_truthy + end + end + + context "with zero quantity" do + let(:quantity) { 0 } + it "returns false for zero quantity" do + expect(item.has_inventory?(inventory)).to be_falsey + end + end + + context "with negative quantity" do + let(:quantity) { -1 } + it "returns false for negative quantity" do + expect(item.has_inventory?(inventory)).to be_falsey + end + end + + context "with nil quantity" do + let(:quantity) { nil } + it "returns false for nil quantity" do + expect(item.has_inventory?(inventory)).to be_falsey + end + end + end +end +describe '#in_request?', :phoenix do + let(:item) { create(:item) } + + context 'when there is a request associated with the item' do + before do + allow(Request).to receive(:by_request_item_id).with(item.id).and_return(double('ActiveRecord::Relation', exists?: true)) + end + + it 'returns true' do + expect(item.in_request?).to be true + end + end + + context 'when there is no request associated with the item' do + before do + allow(Request).to receive(:by_request_item_id).with(item.id).and_return(double('ActiveRecord::Relation', exists?: false)) + end + + it 'returns false' do + expect(item.in_request?).to be false + end + end +end +describe '#is_in_kit?', :phoenix do + let(:organization) { create(:organization) } + let(:item) { create(:item, organization: organization) } + let(:kit_with_item) { create(:kit, organization: organization, line_items: [build(:line_item, item: item)]) } + let(:kit_without_item) { create(:kit, organization: organization) } + + context 'when kits are provided' do + it 'returns false when kits is an empty array' do + kits = [] + expect(item.is_in_kit?(kits)).to be_falsey + end + + it 'returns true when kits contain a kit with the item' do + kits = [kit_with_item] + expect(item.is_in_kit?(kits)).to be_truthy + end + + it 'returns false when kits do not contain a kit with the item' do + kits = [kit_without_item] + expect(item.is_in_kit?(kits)).to be_falsey + end + end + + context 'when kits are not provided' do + before do + allow(organization).to receive(:kits).and_return(kits) + end + + context 'when the organization has no active kits' do + let(:kits) { [] } + + it 'returns false' do + expect(item.is_in_kit?).to be_falsey + end + end + + context 'when active kits do not contain the item' do + let(:kits) { [kit_without_item] } + + it 'returns false' do + expect(item.is_in_kit?).to be_falsey + end + end + + context 'when at least one active kit contains the item' do + let(:kits) { [kit_with_item] } + + it 'returns true' do + expect(item.is_in_kit?).to be_truthy + end + end + end +end +describe '#can_delete?', :phoenix do + let(:organization) { create(:organization) } + let(:item) { build(:item, organization: organization, barcode_count: barcode_count) } + let(:barcode_count) { 0 } + let(:line_items) { [] } + let(:in_request) { false } + + before do + allow(item).to receive(:line_items).and_return(line_items) + allow(item).to receive(:in_request?).and_return(in_request) + allow(item).to receive(:can_deactivate_or_delete?).and_return(can_deactivate_or_delete) + end + + context 'when can_deactivate_or_delete? is true' do + let(:can_deactivate_or_delete) { true } + + context 'and line_items is empty' do + let(:line_items) { [] } + + context 'and barcode_count is not positive' do + let(:barcode_count) { 0 } + + context 'and not in_request' do + let(:in_request) { false } + + it 'returns true when all conditions are met' do + expect(item.can_delete?).to eq(true) + end + end + + context 'and in_request' do + let(:in_request) { true } + + it 'returns false when in_request is true' do + expect(item.can_delete?).to eq(false) + end + end + end + + context 'and barcode_count is positive' do + let(:barcode_count) { 1 } + + it 'returns false when barcode_count is positive' do + expect(item.can_delete?).to eq(false) + end + end + end + + context 'and line_items is not empty' do + let(:line_items) { [double('LineItem')] } + + it 'returns false when line_items is not empty' do + expect(item.can_delete?).to eq(false) + end + end + end + + context 'when can_deactivate_or_delete? is false' do + let(:can_deactivate_or_delete) { false } + + it 'returns false when can_deactivate_or_delete? is false' do + expect(item.can_delete?).to eq(false) + end + end +end +describe '#can_deactivate_or_delete?', :phoenix do + let(:organization) { create(:organization) } + let(:inventory) { instance_double('View::Inventory', organization_id: organization.id) } + let(:item_with_inventory_and_kit) { create(:item, organization: organization, kit: create(:kit), active: true) } + let(:item_with_inventory_no_kit) { create(:item, organization: organization, kit: nil, active: true) } + let(:item_no_inventory_with_kit) { build(:item, organization: organization, kit: create(:kit), active: true) } + let(:item_no_inventory_no_kit) { build(:item, organization: organization, kit: nil, active: true) } + + before do + allow(item_with_inventory_and_kit).to receive(:has_inventory?).with(inventory).and_return(true) + allow(item_with_inventory_no_kit).to receive(:has_inventory?).with(inventory).and_return(true) + allow(item_no_inventory_with_kit).to receive(:has_inventory?).with(inventory).and_return(false) + allow(item_no_inventory_no_kit).to receive(:has_inventory?).with(inventory).and_return(false) + + allow(item_with_inventory_and_kit).to receive(:is_in_kit?).and_return(true) + allow(item_with_inventory_no_kit).to receive(:is_in_kit?).and_return(false) + allow(item_no_inventory_with_kit).to receive(:is_in_kit?).and_return(true) + allow(item_no_inventory_no_kit).to receive(:is_in_kit?).and_return(false) + end + + it 'returns false when the item has inventory and is part of a kit' do + expect(item_with_inventory_and_kit.can_deactivate_or_delete?(inventory)).to eq(false) + end + + it 'returns false when the item has inventory and is not part of a kit' do + expect(item_with_inventory_no_kit.can_deactivate_or_delete?(inventory)).to eq(false) + end + + it 'returns false when the item does not have inventory and is part of a kit' do + expect(item_no_inventory_with_kit.can_deactivate_or_delete?(inventory)).to eq(false) + end + + it 'returns true when the item does not have inventory and is not part of a kit' do + expect(item_no_inventory_no_kit.can_deactivate_or_delete?(inventory)).to eq(true) + end +end +describe '#validate_destroy', :phoenix do + let(:item) { build(:item, can_delete: can_delete) } + + context 'when can_delete? returns true' do + let(:can_delete) { true } + + it 'does not add errors to the base' do + item.validate_destroy + expect(item.errors[:base]).to be_empty + end + + it 'does not abort the operation' do + expect(item).not_to receive(:throw).with(:abort) + item.validate_destroy + end + end + + context 'when can_delete? returns false' do + let(:can_delete) { false } + + it 'adds an error to the base' do + item.validate_destroy + expect(item.errors[:base]).to include('Cannot delete item - it has already been used!') + end + + it 'aborts the operation' do + expect(item).to receive(:throw).with(:abort) + item.validate_destroy + end + end +end +describe '#deactivate!', :phoenix do + let(:organization) { create(:organization) } + let(:item) { build(:item, organization: organization, kit: kit, active: true) } + let(:kit) { nil } + + it 'raises an error when cannot deactivate or delete' do + allow(item).to receive(:can_deactivate_or_delete?).and_return(false) + expect { item.deactivate! }.to raise_error('Cannot deactivate item - it is in a storage location or kit!') + end + + context 'when can deactivate or delete' do + before do + allow(item).to receive(:can_deactivate_or_delete?).and_return(true) + end + + context 'and item is part of a kit' do + let(:kit) { build(:kit) } + + it 'deactivates the kit' do + expect(kit).to receive(:deactivate) + item.deactivate! + end + end + + context 'and item is not part of a kit' do + let(:kit) { nil } + + it 'deactivates the item' do + expect(item).to receive(:update!).with(active: false) + item.deactivate! + end + end + end +end +describe '#other?', :phoenix do + let(:item_with_other_key) { build(:item, partner_key: 'other') } + let(:item_with_different_key) { build(:item, partner_key: 'different') } + + it 'returns true when partner_key is "other"' do + expect(item_with_other_key.other?).to eq(true) + end + + it 'returns false when partner_key is not "other"' do + expect(item_with_different_key.other?).to eq(false) + end +end +describe ".gather_items", :phoenix do + let(:organization) { create(:organization) } + let(:item) { create(:item, organization: organization) } + let(:barcode_item) { create(:barcode_item, organization: organization, barcodeable: item) } + + context "when global is true" do + let(:global) { true } + + it "returns items with barcodeable_id from all barcode_items" do + allow(organization.barcode_items).to receive(:all).and_return([barcode_item]) + expect(Item.gather_items(organization, global)).to contain_exactly(item) + end + end + + context "when global is false" do + let(:global) { false } + + it "returns items with barcodeable_id from barcode_items" do + allow(organization.barcode_items).to receive(:pluck).with(:barcodeable_id).and_return([item.id]) + expect(Item.gather_items(organization, global)).to contain_exactly(item) + end + end + + context "when current_organization has no barcode_items" do + let(:organization) { create(:organization) } + + it "returns an empty collection" do + allow(organization.barcode_items).to receive(:pluck).with(:barcodeable_id).and_return([]) + expect(Item.gather_items(organization, false)).to be_empty + end + end +end +describe '#to_i', :phoenix do + let(:item) { build(:item) } + + it 'returns the id as an integer' do + expect(item.to_i).to eq(item.id) + end + + describe 'when id is nil' do + let(:item) { build(:item, id: nil) } + + it 'returns nil when id is nil' do + expect(item.to_i).to eq(nil) + end + end + + describe 'when id is a valid integer' do + let(:item) { build(:item, id: 123) } + + it 'returns the correct integer value for a valid id' do + expect(item.to_i).to eq(123) + end + end + + describe 'when id is an edge case value' do + let(:item) { build(:item, id: 0) } + + it 'returns 0 for an id of 0' do + expect(item.to_i).to eq(0) + end + end +end +describe '#to_h', :phoenix do + let(:item) { build(:item, name: item_name, id: item_id) } + + it 'returns a hash with name and item_id' do + expect(item.to_h).to eq({ name: item_name, item_id: item_id }) + end + + context 'when name is nil' do + let(:item_name) { nil } + let(:item_id) { 1 } + + it 'returns a hash with nil name' do + expect(item.to_h).to eq({ name: nil, item_id: item_id }) + end + end + + context 'when id is nil' do + let(:item_name) { 'Sample Item' } + let(:item_id) { nil } + + it 'returns a hash with nil item_id' do + expect(item.to_h).to eq({ name: item_name, item_id: nil }) + end + end + + context 'when both name and id are nil' do + let(:item_name) { nil } + let(:item_id) { nil } + + it 'returns a hash with nil values' do + expect(item.to_h).to eq({ name: nil, item_id: nil }) + end + end +end +describe '.csv_export_headers', :phoenix do + it 'returns an array with the correct headers' do + expect(Item.csv_export_headers).to eq(["Name", "Barcodes", "Base Item", "Quantity"]) + end +end +describe '#generate_csv_from_inventory', :phoenix do + let(:organization) { create(:organization) } + let(:base_item) { create(:base_item, organization: organization) } + let(:items) { build_list(:item, 3, organization: organization, base_item: base_item) } + let(:inventory) { instance_double('Inventory') } + + before do + allow(inventory).to receive(:quantity_for).and_return(10) + end + + it 'generates CSV successfully with valid items and inventory' do + csv = Item.generate_csv_from_inventory(items, inventory) + expect(csv).to include(Item.csv_export_headers.join(',')) + end + + it 'includes item names in CSV' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(item.name) + end + end + + it 'includes item barcode counts in CSV' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(item.barcode_count.to_s) + end + end + + it 'includes base item names in CSV' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(item.base_item.name) + end + end + + it 'includes item quantities in CSV' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include('10') + end + end + + context 'when items list is empty' do + let(:items) { [] } + + it 'generates CSV with only headers' do + csv = Item.generate_csv_from_inventory(items, inventory) + expect(csv).to eq(Item.csv_export_headers.join(',') + "\n") + end + end + + context 'when items have missing attributes' do + let(:items) { build_list(:item, 3, organization: organization, base_item: nil) } + + it 'handles missing base item names gracefully' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(Item.normalize_csv_attribute(item.base_item&.name)) + end + end + end + + context 'when inventory has missing quantities for some items' do + before do + allow(inventory).to receive(:quantity_for).and_return(nil) + end + + it 'handles missing quantities gracefully' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(Item.normalize_csv_attribute(nil)) + end + end + end + + it 'includes correct CSV headers' do + csv = Item.generate_csv_from_inventory(items, inventory) + expect(csv.lines.first.chomp).to eq(Item.csv_export_headers.join(',')) + end + + it 'normalizes CSV attributes for item names' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(Item.normalize_csv_attribute(item.name)) + end + end + + it 'normalizes CSV attributes for barcode counts' do + csv = Item.generate_csv_from_inventory(items, inventory) + items.each do |item| + expect(csv).to include(Item.normalize_csv_attribute(item.barcode_count)) + end + end +end +describe '#default_quantity', :phoenix do + let(:item_with_distribution_quantity) { build(:item, distribution_quantity: 30) } + let(:item_without_distribution_quantity) { build(:item, distribution_quantity: nil) } + + it 'returns the distribution_quantity when it is present' do + expect(item_with_distribution_quantity.default_quantity).to eq(30) + end + + it 'returns the default value of 50 when distribution_quantity is not present' do + expect(item_without_distribution_quantity.default_quantity).to eq(50) + end +end +describe "#sync_request_units!", :phoenix do + let(:organization) { create(:organization) } + let(:item) { create(:item, organization: organization) } + let(:existing_request_unit) { organization.request_units.create!(name: "Existing Unit") } + let(:new_request_unit_name) { "New Unit" } + + it "clears existing request_units" do + item.request_units << existing_request_unit + item.sync_request_units!([]) + expect(item.request_units).to be_empty + end + + context "when unit_ids is empty" do + it "does not create any request_units" do + item.sync_request_units!([]) + expect(item.request_units).to be_empty + end + end + + context "when unit_ids contains valid IDs" do + let(:valid_unit) { organization.request_units.create!(name: new_request_unit_name) } + + it "creates request_units with the correct names" do + item.sync_request_units!([valid_unit.id]) + expect(item.request_units.pluck(:name)).to include(new_request_unit_name) + end + end + + context "when no matching request_units in organization" do + it "does not create any request_units" do + item.sync_request_units!([999]) # Assuming 999 is a non-existent ID + expect(item.request_units).to be_empty + end + end +end +describe '#update_associated_kit_name', :phoenix do + let(:organization) { create(:organization) } + let(:kit) { build(:kit, name: 'Old Kit Name') } + let(:item) { build(:item, name: 'New Item Name', kit: kit, organization: organization) } + + it 'updates the kit name to match the item name' do + item.update_associated_kit_name + expect(kit.name).to eq('New Item Name') + end + + context 'when kit is nil' do + let(:item) { build(:item, name: 'New Item Name', kit: nil, organization: organization) } + + it 'does not change the kit name' do + expect { item.update_associated_kit_name }.not_to change { kit.name } + end + end + + context 'when update fails due to validation errors' do + before do + allow(kit).to receive(:update).and_return(false) + end + + it 'returns false indicating failure' do + expect(item.update_associated_kit_name).to be_falsey + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/item_unit_spec.rb b/phoenix-tests/unit/tests/app/models/item_unit_spec.rb new file mode 100644 index 0000000000..0d8f88e8cf --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/item_unit_spec.rb @@ -0,0 +1,6 @@ + +require "rails_helper" + +RSpec.describe ItemUnit do + +end diff --git a/phoenix-tests/unit/tests/app/models/kit_allocation_spec.rb b/phoenix-tests/unit/tests/app/models/kit_allocation_spec.rb new file mode 100644 index 0000000000..937bf82750 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/kit_allocation_spec.rb @@ -0,0 +1,6 @@ + +require "rails_helper" + +RSpec.describe KitAllocation do + +end diff --git a/phoenix-tests/unit/tests/app/models/kit_spec.rb b/phoenix-tests/unit/tests/app/models/kit_spec.rb new file mode 100644 index 0000000000..2557f8636b --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/kit_spec.rb @@ -0,0 +1,230 @@ + +require "rails_helper" + +RSpec.describe Kit do +describe '#can_deactivate?', :phoenix do + let(:organization) { create(:organization) } + let(:item) { create(:item, organization: organization) } + let(:inventory) { View::Inventory.new(organization_id: organization.id) } + + context 'when inventory is provided' do + before do + allow(inventory).to receive(:quantity_for).with(item_id: item.id).and_return(quantity) + end + + context 'when quantity is zero' do + let(:quantity) { 0 } + + it 'returns true if quantity is zero' do + expect(Kit.new.can_deactivate?(inventory)).to be true + end + end + + context 'when quantity is not zero' do + let(:quantity) { 5 } + + it 'returns false if quantity is not zero' do + expect(Kit.new.can_deactivate?(inventory)).to be false + end + end + end + + context 'when no inventory is provided' do + before do + allow_any_instance_of(View::Inventory).to receive(:quantity_for).with(item_id: item.id).and_return(quantity) + end + + context 'when quantity is zero' do + let(:quantity) { 0 } + + it 'returns true if quantity is zero' do + expect(Kit.new.can_deactivate?).to be true + end + end + + context 'when quantity is not zero' do + let(:quantity) { 5 } + + it 'returns false if quantity is not zero' do + expect(Kit.new.can_deactivate?).to be false + end + end + end +end +describe '#deactivate', :phoenix do + let(:kit) { create(:kit, active: true) } + let(:kit_with_item) { create(:kit, :with_item, active: true) } + let(:deactivated_kit) { create(:kit, active: false) } + let(:deactivated_kit_with_item) { create(:kit, :with_item, active: false) } + + it 'deactivates the Kit' do + kit.deactivate + expect(kit.active).to eq(false) + end + + it 'deactivates the associated item' do + kit_with_item.deactivate + expect(kit_with_item.item.active).to eq(false) + end + + context 'when Kit fails to deactivate' do + before do + allow(kit).to receive(:update!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { kit.deactivate }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when associated item fails to deactivate' do + before do + allow(kit_with_item.item).to receive(:update!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { kit_with_item.deactivate }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when Kit is already deactivated' do + let(:kit) { deactivated_kit } + + it 'remains inactive' do + kit.deactivate + expect(kit.active).to eq(false) + end + end + + context 'when associated item is already deactivated' do + let(:kit_with_item) { deactivated_kit_with_item } + + it 'remains inactive' do + kit_with_item.deactivate + expect(kit_with_item.item.active).to eq(false) + end + end + + context 'when item is nil' do + let(:kit) { create(:kit, line_items: []) } + + it 'does not raise an error' do + expect { kit.deactivate }.not_to raise_error + end + end +end +describe '#can_reactivate?', :phoenix do + let(:kit) { build(:kit, line_items: line_items) } + + context 'when no line items are associated' do + let(:line_items) { [] } + + it 'returns true when there are no line items' do + expect(kit.can_reactivate?).to eq(true) + end + end + + context 'when all line items are associated with active items' do + let(:active_item) { build(:item, active: true) } + let(:line_items) { [build(:line_item, item: active_item)] } + + it 'returns true when all items are active' do + expect(kit.can_reactivate?).to eq(true) + end + end + + context 'when some line items are associated with inactive items' do + let(:active_item) { build(:item, active: true) } + let(:inactive_item) { build(:item, active: false) } + let(:line_items) { [build(:line_item, item: active_item), build(:line_item, item: inactive_item)] } + + it 'returns false when some items are inactive' do + expect(kit.can_reactivate?).to eq(false) + end + end + + context 'when all line items are associated with inactive items' do + let(:inactive_item) { build(:item, active: false) } + let(:line_items) { [build(:line_item, item: inactive_item)] } + + it 'returns false when all items are inactive' do + expect(kit.can_reactivate?).to eq(false) + end + end +end +describe '#reactivate', :phoenix do + let(:kit) { build(:kit, active: false) } + let(:item) { build(:item, active: false, kit: kit) } + + before do + allow(kit).to receive(:item).and_return(item) + end + + it 'reactivates the kit' do + kit.reactivate + expect(kit.active).to eq(true) + end + + it 'reactivates the item' do + kit.reactivate + expect(item.active).to eq(true) + end + + describe 'when kit update fails' do + before do + allow(kit).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(kit)) + end + + it 'raises an error and does not reactivate the item' do + expect { kit.reactivate }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'does not change item active status' do + expect(item.active).to eq(false) + end + end + + describe 'when item update fails' do + before do + allow(item).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(item)) + end + + it 'raises an error and does not affect kit reactivation' do + expect { kit.reactivate }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'keeps kit active status unchanged' do + kit.reactivate rescue nil + expect(kit.active).to eq(true) + end + end + + describe 'when an exception is raised during update' do + before do + allow(kit).to receive(:update!).and_raise(StandardError) + end + + it 'raises a standard error' do + expect { kit.reactivate }.to raise_error(StandardError) + end + end +end +describe '#at_least_one_item', :phoenix do + let(:kit_without_items) { build(:kit, line_items: []) } + let(:kit_with_items) { build(:kit) } # by default, the factory adds a line_item + + context 'when there are no line_items' do + it 'adds an error to the base' do + kit_without_items.at_least_one_item + expect(kit_without_items.errors[:base]).to include('At least one item is required') + end + end + + context 'when there is at least one line_item' do + it 'does not add an error to the base' do + kit_with_items.at_least_one_item + expect(kit_with_items.errors[:base]).to be_empty + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/line_item_spec.rb b/phoenix-tests/unit/tests/app/models/line_item_spec.rb new file mode 100644 index 0000000000..3aa8407c93 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/line_item_spec.rb @@ -0,0 +1,44 @@ + +require "rails_helper" + +RSpec.describe LineItem do +describe '#quantity_must_be_a_number_within_range', :phoenix do + let(:line_item) { build(:line_item, quantity: quantity) } + + context 'when quantity is greater than MAX_INT' do + let(:quantity) { 2**31 + 1 } + + it 'adds an error for quantity being too large' do + line_item.valid? + expect(line_item.errors[:quantity]).to include("must be less than #{2**31}") + end + end + + context 'when quantity is less than MIN_INT' do + let(:quantity) { -2**31 - 1 } + + it 'adds an error for quantity being too small' do + line_item.valid? + expect(line_item.errors[:quantity]).to include("must be greater than #{-2**31}") + end + end + + context 'when quantity is within the valid range' do + let(:quantity) { 0 } + + it 'does not add any error' do + line_item.valid? + expect(line_item.errors[:quantity]).to be_empty + end + end + + context 'when quantity is nil' do + let(:quantity) { nil } + + it 'does not add any error' do + line_item.valid? + expect(line_item.errors[:quantity]).to be_empty + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/manufacturer_spec.rb b/phoenix-tests/unit/tests/app/models/manufacturer_spec.rb new file mode 100644 index 0000000000..fdcaa42ed2 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/manufacturer_spec.rb @@ -0,0 +1,123 @@ + +require "rails_helper" + +RSpec.describe Manufacturer do +describe '#volume', :phoenix do + let(:manufacturer) { create(:manufacturer) } + + context 'when there are no donations' do + it 'returns 0' do + expect(manufacturer.volume).to eq(0) + end + end + + context 'when there are donations but no line items' do + let!(:donation_without_items) { create(:manufacturer_donation, manufacturer: manufacturer) } + + it 'returns 0' do + expect(manufacturer.volume).to eq(0) + end + end + + context 'when there are donations with line items' do + let!(:donation_with_items) { create(:manufacturer_donation, :with_items, manufacturer: manufacturer) } + + it 'returns the sum of quantities' do + expect(manufacturer.volume).to eq(donation_with_items.line_items.sum(:quantity)) + end + end + + context 'when the sum of quantities is zero' do + let!(:donation_with_items) { create(:manufacturer_donation, :with_items, manufacturer: manufacturer) } + + before do + allow_any_instance_of(LineItem).to receive(:quantity).and_return(0) + end + + it 'returns 0' do + expect(manufacturer.volume).to eq(0) + end + end + + context 'when the sum of quantities is greater than zero' do + let!(:donation_with_items) { create(:manufacturer_donation, :with_items, manufacturer: manufacturer) } + + it 'returns the correct sum' do + expect(manufacturer.volume).to eq(donation_with_items.line_items.sum(:quantity)) + end + end +end +describe '#by_donation_count', :phoenix do + let(:organization) { create(:organization) } + let(:manufacturer_with_donations) do + create(:manufacturer, organization: organization).tap do |manufacturer| + create(:manufacturer_donation, :with_items, manufacturer: manufacturer, organization: organization, issued_at: 1.day.ago) + end + end + let(:manufacturer_without_donations) { create(:manufacturer, organization: organization) } + + it 'includes manufacturers with donations within the default count limit' do + result = Manufacturer.by_donation_count + expect(result).to include(manufacturer_with_donations) + end + + it 'excludes manufacturers without donations within the default count limit' do + result = Manufacturer.by_donation_count + expect(result).not_to include(manufacturer_without_donations) + end + + context 'when a date_range is specified' do + let(:date_range) { 2.days.ago..Time.current } + + it 'includes manufacturers with donations within the specified date range' do + result = Manufacturer.by_donation_count(10, date_range) + expect(result).to include(manufacturer_with_donations) + end + + it 'returns no manufacturers if there are no donations in the specified date range' do + result = Manufacturer.by_donation_count(10, 3.days.ago..2.days.ago) + expect(result).to be_empty + end + end + + it 'excludes manufacturers with zero donation quantities' do + result = Manufacturer.by_donation_count + expect(result).not_to include(manufacturer_without_donations) + end + + it 'limits the number of manufacturers returned to the specified count' do + create_list(:manufacturer_donation, 15, :with_items, manufacturer: manufacturer_with_donations, organization: organization, issued_at: 1.day.ago) + result = Manufacturer.by_donation_count(5) + expect(result.size).to eq(5) + end + + it 'orders manufacturers by donation count in descending order' do + another_manufacturer = create(:manufacturer, organization: organization) + create(:manufacturer_donation, :with_items, manufacturer: another_manufacturer, organization: organization, issued_at: 1.day.ago, quantity: 20) + result = Manufacturer.by_donation_count + expect(result.first).to eq(another_manufacturer) + end +end +describe "#exists_in_org?", :phoenix do + let(:organization) { create(:organization) } + let(:manufacturer) { build(:manufacturer, organization: organization, name: manufacturer_name) } + let(:manufacturer_name) { "Test Manufacturer" } + + it "returns true when the manufacturer exists in the organization" do + create(:manufacturer, organization: organization, name: manufacturer_name) + expect(manufacturer.exists_in_org?).to be true + end + + it "returns false when the manufacturer does not exist in the organization" do + expect(manufacturer.exists_in_org?).to be false + end + + describe "when the organization has no manufacturers" do + let(:organization) { create(:organization) } + + it "returns false" do + expect(manufacturer.exists_in_org?).to be false + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/ndbn_member_spec.rb b/phoenix-tests/unit/tests/app/models/ndbn_member_spec.rb new file mode 100644 index 0000000000..50cf8fd54c --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/ndbn_member_spec.rb @@ -0,0 +1,71 @@ + +require "rails_helper" + +RSpec.describe NDBNMember do +describe '#full_name', :phoenix do + let(:ndbn_member) { build(:ndbn_member, ndbn_member_id: member_id, account_name: account_name) } + + context 'when both ndbn_member_id and account_name are present' do + let(:member_id) { 123 } + let(:account_name) { 'Test Account' } + + it 'returns a concatenated string with id and account name' do + expect(ndbn_member.full_name).to eq('123 - Test Account') + end + end + + context 'when account_name is nil' do + let(:member_id) { 123 } + let(:account_name) { nil } + + it 'returns a string with ndbn_member_id and a hyphen' do + expect(ndbn_member.full_name).to eq('123 - ') + end + end + + context 'when account_name is empty' do + let(:member_id) { 123 } + let(:account_name) { '' } + + it 'returns a string with ndbn_member_id and a hyphen' do + expect(ndbn_member.full_name).to eq('123 - ') + end + end + + context 'when ndbn_member_id is nil' do + let(:member_id) { nil } + let(:account_name) { 'Test Account' } + + it 'returns a string with a hyphen and account name' do + expect(ndbn_member.full_name).to eq(' - Test Account') + end + end + + context 'when ndbn_member_id is empty' do + let(:member_id) { '' } + let(:account_name) { 'Test Account' } + + it 'returns a string with a hyphen and account name' do + expect(ndbn_member.full_name).to eq(' - Test Account') + end + end + + context 'when both ndbn_member_id and account_name are nil' do + let(:member_id) { nil } + let(:account_name) { nil } + + it 'returns a string with just a hyphen' do + expect(ndbn_member.full_name).to eq(' - ') + end + end + + context 'when both ndbn_member_id and account_name are empty' do + let(:member_id) { '' } + let(:account_name) { '' } + + it 'returns a string with just a hyphen' do + expect(ndbn_member.full_name).to eq(' - ') + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/organization_spec.rb b/phoenix-tests/unit/tests/app/models/organization_spec.rb new file mode 100644 index 0000000000..0cf15414ce --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/organization_spec.rb @@ -0,0 +1,1216 @@ + +require "rails_helper" + +RSpec.describe Organization do +describe '#other', :phoenix do + let(:organization_with_other_key) { create(:organization, partner_key: 'other') } + let(:organization_with_different_key) { create(:organization, partner_key: 'different') } + + context 'when records have partner_key as other' do + it 'returns records with partner_key as other' do + expect(Organization.other).to include(organization_with_other_key) + end + end + + context 'when no records have partner_key as other' do + before { organization_with_other_key.destroy } + + it 'returns an empty collection' do + expect(Organization.other).to be_empty + end + end + + context 'when records have different partner_key values' do + it 'does not return records with different partner_key values' do + expect(Organization.other).not_to include(organization_with_different_key) + end + end +end +describe '#during', :phoenix do + let(:organization) { create(:organization) } + let!(:line_item_within_range) { create(:line_item, created_at: '2023-01-15', itemizable: organization) } + let!(:line_item_outside_range) { create(:line_item, created_at: '2022-12-31', itemizable: organization) } + let!(:line_item_edge_case) { create(:line_item, created_at: '2023-01-01', itemizable: organization) } + + context 'when there are line items within the date range' do + it 'returns correct count for line items within the date range' do + result = organization.during('2023-01-01', '2023-01-31') + expect(result.first.amount).to eq(2) + end + + it 'returns correct name for line items within the date range' do + result = organization.during('2023-01-01', '2023-01-31') + expect(result.first.name).to eq(organization.name) + end + end + + context 'when there are no line items in the date range' do + it 'returns no results' do + result = organization.during('2024-01-01', '2024-01-31') + expect(result).to be_empty + end + end + + context 'when the date range is invalid (start date is after end date)' do + it 'returns no results for invalid date range' do + result = organization.during('2023-02-01', '2023-01-01') + expect(result).to be_empty + end + end + + context 'when the start date equals the end date' do + it 'returns correct count for edge case where start date equals end date' do + result = organization.during('2023-01-01', '2023-01-01') + expect(result.first.amount).to eq(1) + end + + it 'returns correct name for edge case where start date equals end date' do + result = organization.during('2023-01-01', '2023-01-01') + expect(result.first.name).to eq(organization.name) + end + end + + context 'when only start date is provided' do + before do + allow(Time.zone).to receive(:now).and_return(Time.zone.parse('2023-01-31')) + end + + it 'returns correct count using current date as default end date' do + result = organization.during('2023-01-01') + expect(result.first.amount).to eq(2) + end + + it 'returns correct name using current date as default end date' do + result = organization.during('2023-01-01') + expect(result.first.name).to eq(organization.name) + end + end +end +describe '#top', :phoenix do + let(:organization) { create(:organization) } + + context 'when there are no items' do + it 'returns an empty array' do + expect(organization.top).to eq([]) + end + end + + context 'when there are fewer items than the limit' do + let!(:line_items) { create_list(:line_item, 3, itemizable: organization) } + + it 'returns all items' do + expect(organization.top).to match_array(line_items) + end + end + + context 'when there are as many items as the limit' do + let!(:line_items) { create_list(:line_item, 5, itemizable: organization) } + + it 'returns exactly the limit number of items' do + expect(organization.top).to match_array(line_items) + end + end + + context 'when there are more items than the limit' do + let!(:line_items) { create_list(:line_item, 10, itemizable: organization) } + + it 'returns the top items up to the limit' do + expect(organization.top.size).to eq(5) + end + end + + context 'when items have varying counts' do + let!(:line_items) do + create(:line_item, itemizable: organization, id: 1) + create(:line_item, itemizable: organization, id: 2) + create(:line_item, itemizable: organization, id: 3) + create(:line_item, itemizable: organization, id: 4) + create(:line_item, itemizable: organization, id: 5) + create(:line_item, itemizable: organization, id: 6) + create(:line_item, itemizable: organization, id: 7) + create(:line_item, itemizable: organization, id: 8) + create(:line_item, itemizable: organization, id: 9) + create(:line_item, itemizable: organization, id: 10) + end + + it 'orders items by count of line_items.id in descending order' do + top_items = organization.top + expect(top_items).to eq(top_items.sort_by { |item| -item.id }) + end + end +end +describe '#bottom', :phoenix do + let(:organization_with_no_line_items) { create(:organization) } + let(:organization_with_few_line_items) { create(:organization) } + let(:organization_with_exact_line_items) { create(:organization) } + let(:organization_with_many_line_items) { create(:organization) } + let(:organization_with_ordered_line_items) { create(:organization) } + + before do + create_list(:line_item, 0, itemizable: organization_with_no_line_items) + create_list(:line_item, 3, itemizable: organization_with_few_line_items) + create_list(:line_item, 5, itemizable: organization_with_exact_line_items) + create_list(:line_item, 10, itemizable: organization_with_many_line_items) + create_list(:line_item, 1, itemizable: organization_with_ordered_line_items) + end + + it 'returns an empty array when there are no line items' do + expect(organization_with_no_line_items.bottom).to eq([]) + end + + it 'returns all organizations when there are fewer organizations than the limit' do + result = organization_with_few_line_items.bottom(5) + expect(result.size).to eq(1) + end + + it 'returns exactly the limit number of organizations when there are as many organizations as the limit' do + result = organization_with_exact_line_items.bottom(5) + expect(result.size).to eq(1) + end + + it 'returns the bottom organizations when there are more organizations than the limit' do + result = organization_with_many_line_items.bottom(5) + expect(result.size).to eq(5) + end + + it 'orders organizations by the count of line_items in ascending order' do + result = organization_with_many_line_items.bottom(5) + expect(result.first).to eq(organization_with_ordered_line_items) + end + + it 'handles invalid limit input gracefully without raising an error' do + expect { organization_with_many_line_items.bottom(-1) }.not_to raise_error + end +end +describe '#all', :phoenix do + let(:organization) { create(:organization) } + let(:barcode_item_with_org_id) { create(:barcode_item, organization: organization) } + let(:barcode_item_with_base_item) { create(:global_barcode_item) } + let(:barcode_item_with_neither) { create(:barcode_item, organization: nil, barcodeable_type: 'OtherType') } + let(:barcode_item_with_both) { create(:barcode_item, organization: organization, barcodeable_type: 'BaseItem') } + + before do + barcode_item_with_org_id + barcode_item_with_base_item + barcode_item_with_neither + barcode_item_with_both + end + + it 'includes items with matching organization_id' do + result = organization.all + expect(result).to include(barcode_item_with_org_id) + end + + it 'excludes items with neither condition matching' do + result = organization.all + expect(result).not_to include(barcode_item_with_neither) + end + + it 'includes items with barcodeable_type BaseItem' do + result = organization.all + expect(result).to include(barcode_item_with_base_item) + end + + it 'includes items with both conditions matching' do + result = organization.all + expect(result).to include(barcode_item_with_both) + end +end +describe '#upcoming', :phoenix do + let(:organization) { create(:organization) } + let(:this_week_distribution) { build(:distribution, organization: organization, issued_at: Time.zone.today) } + let(:future_distribution) { build(:distribution, organization: organization, issued_at: Time.zone.today + 1.day) } + let(:past_distribution) { build(:distribution, :past, organization: organization) } + + it 'returns records scheduled for today' do + allow(organization).to receive(:this_week).and_return([this_week_distribution]) + allow(this_week_distribution).to receive(:scheduled).and_return([this_week_distribution]) + expect(organization.upcoming).to include(this_week_distribution) + end + + it 'returns records scheduled for future dates' do + allow(organization).to receive(:this_week).and_return([future_distribution]) + allow(future_distribution).to receive(:scheduled).and_return([future_distribution]) + expect(organization.upcoming).to include(future_distribution) + end + + it 'does not return records scheduled for past dates' do + allow(organization).to receive(:this_week).and_return([past_distribution]) + allow(past_distribution).to receive(:scheduled).and_return([]) + expect(organization.upcoming).not_to include(past_distribution) + end + + it 'returns an empty collection when there are no records' do + allow(organization).to receive(:this_week).and_return([]) + expect(organization.upcoming).to be_empty + end +end +describe '#flipper_id', :phoenix do + let(:organization) { build(:organization, id: 123) } + + it 'returns the correct string format for a valid integer id' do + expect(organization.flipper_id).to eq('Org:123') + end + + context 'when id is nil' do + before do + allow(organization).to receive(:id).and_return(nil) + end + + it 'returns the correct string format for nil id' do + expect(organization.flipper_id).to eq('Org:') + end + end + + context 'when id is a non-integer value' do + before do + allow(organization).to receive(:id).and_return('non-integer') + end + + it 'returns the correct string format for non-integer id' do + expect(organization.flipper_id).to eq('Org:non-integer') + end + end +end +describe "#assign_attributes_from_account_request", :phoenix do + let(:account_request) { build(:account_request) } + let(:organization) { build(:organization) } + + it "assigns name from account_request" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.name).to eq(account_request.organization_name) + end + + it "assigns url from account_request" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.url).to eq(account_request.organization_website) + end + + it "assigns email from account_request" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.email).to eq(account_request.email) + end + + it "assigns account_request_id from account_request" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.account_request_id).to eq(account_request.id) + end + + describe "when organization_name is missing" do + let(:account_request) { build(:account_request, organization_name: nil) } + + it "sets name to nil" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.name).to be_nil + end + end + + describe "when organization_website is missing" do + let(:account_request) { build(:account_request, organization_website: nil) } + + it "sets url to nil" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.url).to be_nil + end + end + + describe "when email is missing" do + let(:account_request) { build(:account_request, email: nil) } + + it "sets email to nil" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.email).to be_nil + end + end + + describe "when account_request_id is missing" do + let(:account_request) { build(:account_request, id: nil) } + + it "sets account_request_id to nil" do + organization.assign_attributes_from_account_request(account_request) + expect(organization.account_request_id).to be_nil + end + end + + describe "when account_request is nil" do + let(:account_request) { nil } + + it "raises NoMethodError" do + expect { organization.assign_attributes_from_account_request(account_request) }.to raise_error(NoMethodError) + end + end +end +describe '#to_param', :phoenix do + let(:organization) { build(:organization, short_name: short_name) } + + context 'when short_name is a valid string' do + let(:short_name) { 'valid_short_name' } + + it 'returns the short_name' do + expect(organization.to_param).to eq('valid_short_name') + end + end + + context 'when short_name is nil' do + let(:short_name) { nil } + + it 'returns nil' do + expect(organization.to_param).to be_nil + end + end + + context 'when short_name is an empty string' do + let(:short_name) { '' } + + it 'returns an empty string' do + expect(organization.to_param).to eq('') + end + end +end +describe "#display_users", :phoenix do + let(:organization) { build(:organization) } + + before do + allow(organization).to receive(:users).and_return(users) + end + + context "with multiple users" do + let(:users) { build_list(:user, 3, organization: organization, email: 'user@example.com') } + + it "joins multiple user emails with commas" do + expect(organization.display_users).to eq('user@example.com, user@example.com, user@example.com') + end + end + + context "with no users" do + let(:users) { [] } + + it "returns an empty string when there are no users" do + expect(organization.display_users).to eq('') + end + end + + context "with a single user" do + let(:users) { [build(:user, email: 'single@example.com', organization: organization)] } + + it "returns the email when there is only one user" do + expect(organization.display_users).to eq('single@example.com') + end + end + + context "with users having nil emails" do + let(:users) { [build(:user, email: nil, organization: organization)] } + + it "returns an empty string for users with nil emails" do + expect(organization.display_users).to eq('') + end + end + + context "with users having empty string emails" do + let(:users) { [build(:user, email: "", organization: organization)] } + + it "returns an empty string for users with empty string emails" do + expect(organization.display_users).to eq('') + end + end + + context "with a mix of valid, nil, and empty string emails" do + let(:users) do + [ + build(:user, email: "valid@example.com", organization: organization), + build(:user, email: nil, organization: organization), + build(:user, email: "", organization: organization) + ] + end + + it "returns only valid emails, ignoring nil and empty string emails" do + expect(organization.display_users).to eq('valid@example.com') + end + end +end +describe '#ordered_requests', :phoenix do + let(:organization) { create(:organization) } + + it 'returns an empty array when there are no requests' do + expect(organization.ordered_requests).to eq([]) + end + + describe 'with a single request' do + let!(:request) { create(:request, organization: organization) } + + it 'returns the single request' do + expect(organization.ordered_requests).to eq([request]) + end + end + + describe 'with multiple requests having the same status' do + let!(:request1) { create(:request, organization: organization, updated_at: 1.day.ago) } + let!(:request2) { create(:request, organization: organization, updated_at: 2.days.ago) } + + it 'orders them by updated_at in descending order' do + expect(organization.ordered_requests).to eq([request1, request2]) + end + end + + describe 'with multiple requests having different statuses' do + let!(:request1) { create(:request, organization: organization, status: 'fulfilled') } + let!(:request2) { create(:request, organization: organization, status: 'started') } + + it 'orders them by status in ascending order' do + expect(organization.ordered_requests).to eq([request2, request1]) + end + end + + describe 'with multiple requests having different statuses and updated_at timestamps' do + let!(:request1) { create(:request, organization: organization, status: 'fulfilled', updated_at: 1.day.ago) } + let!(:request2) { create(:request, organization: organization, status: 'started', updated_at: 2.days.ago) } + let!(:request3) { create(:request, organization: organization, status: 'started', updated_at: 3.days.ago) } + + it 'orders them by status in ascending order and updated_at in descending order' do + expect(organization.ordered_requests).to eq([request2, request3, request1]) + end + end +end +describe '#address', :phoenix do + let(:organization) { build(:organization, street: street, city: city, state: state, zipcode: zipcode) } + let(:street) { '1500 Remount Road' } + let(:city) { 'Front Royal' } + let(:state) { 'VA' } + let(:zipcode) { '22630' } + + it 'returns full address when all components are present' do + expect(organization.address).to eq('1500 Remount Road, Front Royal, VA 22630') + end + + context 'when only street and city are present' do + let(:state) { nil } + let(:zipcode) { nil } + + it 'returns address with street and city when only they are present' do + expect(organization.address).to eq('1500 Remount Road, Front Royal') + end + end + + context 'when only state and zipcode are present' do + let(:street) { nil } + let(:city) { nil } + + it 'returns address with state and zipcode when only they are present' do + expect(organization.address).to eq('VA 22630') + end + end + + context 'when only street is present' do + let(:city) { nil } + let(:state) { nil } + let(:zipcode) { nil } + + it 'returns address with only street when only it is present' do + expect(organization.address).to eq('1500 Remount Road') + end + end + + context 'when only city is present' do + let(:street) { nil } + let(:state) { nil } + let(:zipcode) { nil } + + it 'returns address with only city when only it is present' do + expect(organization.address).to eq('Front Royal') + end + end + + context 'when only state is present' do + let(:street) { nil } + let(:city) { nil } + let(:zipcode) { nil } + + it 'returns address with only state when only it is present' do + expect(organization.address).to eq('VA') + end + end + + context 'when only zipcode is present' do + let(:street) { nil } + let(:city) { nil } + let(:state) { nil } + + it 'returns address with only zipcode when only it is present' do + expect(organization.address).to eq('22630') + end + end + + context 'when no components are present' do + let(:street) { nil } + let(:city) { nil } + let(:state) { nil } + let(:zipcode) { nil } + + it 'returns empty string when no components are present' do + expect(organization.address).to eq('') + end + end +end +describe '#address_changed?', :phoenix do + let(:organization) { build(:organization) } + + it 'returns true if street has changed' do + allow(organization).to receive(:street_changed?).and_return(true) + expect(organization.address_changed?).to be true + end + + it 'returns true if city has changed' do + allow(organization).to receive(:city_changed?).and_return(true) + expect(organization.address_changed?).to be true + end + + it 'returns true if state has changed' do + allow(organization).to receive(:state_changed?).and_return(true) + expect(organization.address_changed?).to be true + end + + it 'returns true if zipcode has changed' do + allow(organization).to receive(:zipcode_changed?).and_return(true) + expect(organization.address_changed?).to be true + end + + it 'returns false if none of the address fields have changed' do + allow(organization).to receive(:street_changed?).and_return(false) + allow(organization).to receive(:city_changed?).and_return(false) + allow(organization).to receive(:state_changed?).and_return(false) + allow(organization).to receive(:zipcode_changed?).and_return(false) + expect(organization.address_changed?).to be false + end +end +describe '#address_inline', :phoenix do + let(:organization_with_multiline_address) { build(:organization, street: "123 Main St\nSuite 100\nBuilding 5") } + let(:organization_with_whitespace_address) { build(:organization, street: " 123 Main St \n Suite 100 ") } + let(:organization_with_empty_lines_address) { build(:organization, street: "123 Main St\n\nSuite 100") } + let(:organization_with_single_line_address) { build(:organization, street: "123 Main St") } + let(:organization_with_empty_address) { build(:organization, street: "") } + + it 'returns a single line for a multiline address' do + expect(organization_with_multiline_address.address_inline).to eq('123 Main St, Suite 100, Building 5') + end + + it 'strips leading and trailing whitespace from each line' do + expect(organization_with_whitespace_address.address_inline).to eq('123 Main St, Suite 100') + end + + it 'removes empty lines from the address' do + expect(organization_with_empty_lines_address.address_inline).to eq('123 Main St, Suite 100') + end + + it 'returns the same line for a single line address' do + expect(organization_with_single_line_address.address_inline).to eq('123 Main St') + end + + it 'returns an empty string for an empty address' do + expect(organization_with_empty_address.address_inline).to eq('') + end +end +describe '#total_inventory', :phoenix do + let(:organization_with_items) { create(:organization, :with_items) } + let(:organization_without_locations) { create(:organization) } + let(:organization_with_empty_locations) { create(:organization) } + + it 'calculates total inventory for an organization with multiple storage locations and items' do + expect(organization_with_items.total_inventory).to eq(100) # assuming 100 is the expected total + end + + it 'returns zero when the organization has no storage locations' do + expect(organization_without_locations.total_inventory).to eq(0) + end + + it 'returns zero when storage locations have no items' do + expect(organization_with_empty_locations.total_inventory).to eq(0) + end + + it 'raises an error when there is a problem in data retrieval or method calls' do + allow(View::Inventory).to receive(:total_inventory).and_raise(StandardError) + expect { organization_with_items.total_inventory }.to raise_error(StandardError) + end +end +describe ".seed_items", :phoenix do + let(:organization) { create(:organization) } + let(:organizations) { create_list(:organization, 3) } + let(:base_items) { create_list(:base_item, 5) } + + before do + allow(BaseItem).to receive(:all).and_return(base_items) + end + + it "seeds items for all organizations when no argument is provided" do + expect(Organization).to receive(:all).and_return(organizations) + organizations.each do |org| + expect(org).to receive(:seed_items).with(base_items) + expect(org).to receive(:reload) + end + Organization.seed_items + end + + it "seeds items for a single organization when one organization is provided" do + expect(organization).to receive(:seed_items).with(base_items) + expect(organization).to receive(:reload) + Organization.seed_items(organization) + end + + it "seeds items for multiple organizations when an array of organizations is provided" do + organizations.each do |org| + expect(org).to receive(:seed_items).with(base_items) + expect(org).to receive(:reload) + end + Organization.seed_items(organizations) + end + + describe "when BaseItem.all returns an empty array" do + before do + allow(BaseItem).to receive(:all).and_return([]) + end + + it "does not seed any items" do + expect(organization).not_to receive(:seed_items) + Organization.seed_items(organization) + end + end + + describe "when BaseItem.all returns a non-empty array" do + it "seeds items based on the base items" do + expect(organization).to receive(:seed_items).with(base_items) + Organization.seed_items(organization) + end + end + + describe "when an organization fails to seed items" do + before do + allow_any_instance_of(Organization).to receive(:seed_items).and_raise(StandardError) + end + + it "handles the exception gracefully" do + expect { Organization.seed_items(organization) }.not_to raise_error + end + end + + describe "when an organization successfully seeds items" do + it "reloads the organization" do + expect(organization).to receive(:reload) + Organization.seed_items(organization) + end + end +end +describe '#seed_items', :phoenix do + let(:organization) { create(:organization) } + let(:item) { build(:item, name: 'Item 1', partner_key: 'partner_key_1') } + let(:duplicate_item) { build(:item, name: 'Item 1', partner_key: 'partner_key_2') } + let(:other_item) { create(:item, name: 'Item 1', partner_key: 'other', organization: organization) } + + it 'successfully creates a new item' do + expect { organization.seed_items([item]) }.to change { organization.items.count }.by(1) + end + + context 'when encountering a duplicate item' do + before do + organization.items.create!(name: 'Item 1', partner_key: 'partner_key_1') + end + + it 'logs a duplicate item message' do + expect(Rails.logger).to receive(:info).with('[SEED] Duplicate item! Item 1') + organization.seed_items([duplicate_item]) rescue nil + end + + context 'and the existing item is other' do + it 'updates the existing item with the new partner_key' do + organization.seed_items([other_item]) + expect(other_item.reload.partner_key).to eq('partner_key_2') + end + + it 'reloads the existing item' do + expect(other_item).to receive(:reload) + organization.seed_items([other_item]) + end + end + + context 'and the existing item does not meet update conditions' do + it 'skips the item' do + expect { organization.seed_items([duplicate_item]) }.not_to change { other_item.reload.partner_key } + end + end + end + + it 'reloads the organization after processing items' do + expect(organization).to receive(:reload) + organization.seed_items([item]) + end +end +describe '#valid_items', :phoenix do + let(:organization) { create(:organization) } + + let(:inactive_item) { build(:item, :inactive, visible_to_partners: true, organization: organization) } + let(:invisible_item) { build(:item, active: true, visible_to_partners: false, organization: organization) } + let(:inactive_invisible_item) { build(:item, :inactive, visible_to_partners: false, organization: organization) } + let(:active_visible_item) { build(:item, :active, visible_to_partners: true, organization: organization) } + + it 'returns an empty array when there are no items' do + expect(organization.valid_items).to eq([]) + end + + it 'returns an empty array when no items are active' do + inactive_item.save + invisible_item.save + inactive_invisible_item.save + expect(organization.valid_items).to eq([]) + end + + it 'returns an empty array when no items are visible' do + inactive_item.save + invisible_item.save + inactive_invisible_item.save + expect(organization.valid_items).to eq([]) + end + + it 'returns an empty array when no items are active and visible' do + inactive_item.save + invisible_item.save + inactive_invisible_item.save + expect(organization.valid_items).to eq([]) + end + + it 'returns a list of valid items when items are active and visible' do + active_visible_item.save + expect(organization.valid_items).to eq([ + { + id: active_visible_item.id, + partner_key: active_visible_item.partner_key, + name: active_visible_item.name + } + ]) + end + + describe 'when items have different attributes' do + before { active_visible_item.save } + + it 'returns item with correct id' do + result = organization.valid_items.first + expect(result[:id]).to eq(active_visible_item.id) + end + + it 'returns item with correct partner_key' do + result = organization.valid_items.first + expect(result[:partner_key]).to eq(active_visible_item.partner_key) + end + + it 'returns item with correct name' do + result = organization.valid_items.first + expect(result[:name]).to eq(active_visible_item.name) + end + end +end +describe "#item_id_to_display_string_map", :phoenix do + let(:organization) { build(:organization) } + + context "when valid_items is empty" do + before do + allow(organization).to receive(:valid_items).and_return([]) + end + + it "returns an empty hash" do + expect(organization.item_id_to_display_string_map).to eq({}) + end + end + + context "when valid_items has valid items" do + let(:valid_items) do + [ + { id: "1", name: "Item One" }, + { id: "2", name: "Item Two" } + ] + end + + before do + allow(organization).to receive(:valid_items).and_return(valid_items) + end + + it "maps item ids to names" do + expect(organization.item_id_to_display_string_map).to eq({ 1 => "Item One", 2 => "Item Two" }) + end + end + + context "when valid_items has non-integer ids" do + let(:valid_items) do + [ + { id: "abc", name: "Item ABC" }, + { id: "2", name: "Item Two" } + ] + end + + before do + allow(organization).to receive(:valid_items).and_return(valid_items) + end + + it "ignores items with non-integer ids" do + expect(organization.item_id_to_display_string_map).to eq({ 2 => "Item Two" }) + end + end + + context "when valid_items has duplicate ids" do + let(:valid_items) do + [ + { id: "1", name: "Item One" }, + { id: "1", name: "Duplicate Item One" } + ] + end + + before do + allow(organization).to receive(:valid_items).and_return(valid_items) + end + + it "overwrites duplicate ids with the last occurrence" do + expect(organization.item_id_to_display_string_map).to eq({ 1 => "Duplicate Item One" }) + end + end + + context "when valid_items has nil or missing names" do + let(:valid_items) do + [ + { id: "1", name: nil }, + { id: "2" } + ] + end + + before do + allow(organization).to receive(:valid_items).and_return(valid_items) + end + + it "maps ids to nil when names are nil or missing" do + expect(organization.item_id_to_display_string_map).to eq({ 1 => nil, 2 => nil }) + end + end +end +describe "#valid_items_for_select", :phoenix do + let(:organization) { build(:organization) } + let(:item1) { { name: "Item A", id: 1 } } + let(:item2) { { name: "Item B", id: 2 } } + let(:duplicate_name_item) { { name: "Item A", id: 3 } } + let(:duplicate_id_item) { { name: "Item C", id: 1 } } + let(:sorted_items) { [item1, item2] } + let(:unsorted_items) { [item2, item1] } + + before do + allow(organization).to receive(:valid_items).and_return(valid_items) + end + + context "when there are no valid items" do + let(:valid_items) { [] } + + it "returns an empty array" do + expect(organization.valid_items_for_select).to eq([]) + end + end + + context "when there are multiple unique items" do + let(:valid_items) { [item1, item2] } + + it "returns a sorted array of name-id pairs" do + expect(organization.valid_items_for_select).to eq([["Item A", 1], ["Item B", 2]]) + end + end + + context "when there are items with duplicate names but different ids" do + let(:valid_items) { [item1, duplicate_name_item] } + + it "returns items with duplicate names but different ids" do + expect(organization.valid_items_for_select).to eq([["Item A", 1], ["Item A", 3]]) + end + end + + context "when there are items with duplicate ids but different names" do + let(:valid_items) { [item1, duplicate_id_item] } + + it "returns items with duplicate ids but different names" do + expect(organization.valid_items_for_select).to eq([["Item A", 1], ["Item C", 1]]) + end + end + + context "when items are already sorted" do + let(:valid_items) { sorted_items } + + it "returns the same sorted array" do + expect(organization.valid_items_for_select).to eq([["Item A", 1], ["Item B", 2]]) + end + end + + context "when items are not initially sorted" do + let(:valid_items) { unsorted_items } + + it "sorts items" do + expect(organization.valid_items_for_select).to eq([["Item A", 1], ["Item B", 2]]) + end + end +end +describe '#from_email', :phoenix do + let(:organization_with_email) { build(:organization, email: 'contact@example.com') } + let(:organization_without_email) { build(:organization, email: '') } + + it 'returns admin email when email is blank' do + allow(organization_without_email).to receive(:get_admin_email).and_return('admin@example.com') + expect(organization_without_email.from_email).to eq('admin@example.com') + end + + it 'returns email when email is present' do + expect(organization_with_email.from_email).to eq('contact@example.com') + end +end +describe "#earliest_reporting_year", :phoenix do + let(:organization) { create(:organization, created_at: created_at) } + let(:created_at) { Time.zone.local(2020, 1, 1) } + + it "returns the created_at year when there are no donations, purchases, or distributions" do + expect(organization.earliest_reporting_year).to eq(created_at.year) + end + + describe "when only donations exist" do + let!(:donation) { create(:donation, organization: organization, issued_at: donation_issued_at) } + let(:donation_issued_at) { Time.zone.local(2019, 1, 1) } + + it "returns the earliest year between created_at and donations" do + expect(organization.earliest_reporting_year).to eq(donation_issued_at.year) + end + end + + describe "when only purchases exist" do + let!(:purchase) { create(:purchase, organization: organization, issued_at: purchase_issued_at) } + let(:purchase_issued_at) { Time.zone.local(2018, 1, 1) } + + it "returns the earliest year between created_at and purchases" do + expect(organization.earliest_reporting_year).to eq(purchase_issued_at.year) + end + end + + describe "when only distributions exist" do + let!(:distribution) { create(:distribution, organization: organization, issued_at: distribution_issued_at) } + let(:distribution_issued_at) { Time.zone.local(2017, 1, 1) } + + it "returns the earliest year between created_at and distributions" do + expect(organization.earliest_reporting_year).to eq(distribution_issued_at.year) + end + end + + describe "when donations and purchases exist" do + let!(:donation) { create(:donation, organization: organization, issued_at: donation_issued_at) } + let!(:purchase) { create(:purchase, organization: organization, issued_at: purchase_issued_at) } + let(:donation_issued_at) { Time.zone.local(2019, 1, 1) } + let(:purchase_issued_at) { Time.zone.local(2018, 1, 1) } + + it "returns the earliest year among created_at, donations, and purchases" do + expect(organization.earliest_reporting_year).to eq(purchase_issued_at.year) + end + end + + describe "when donations and distributions exist" do + let!(:donation) { create(:donation, organization: organization, issued_at: donation_issued_at) } + let!(:distribution) { create(:distribution, organization: organization, issued_at: distribution_issued_at) } + let(:donation_issued_at) { Time.zone.local(2019, 1, 1) } + let(:distribution_issued_at) { Time.zone.local(2017, 1, 1) } + + it "returns the earliest year among created_at, donations, and distributions" do + expect(organization.earliest_reporting_year).to eq(distribution_issued_at.year) + end + end + + describe "when purchases and distributions exist" do + let!(:purchase) { create(:purchase, organization: organization, issued_at: purchase_issued_at) } + let!(:distribution) { create(:distribution, organization: organization, issued_at: distribution_issued_at) } + let(:purchase_issued_at) { Time.zone.local(2018, 1, 1) } + let(:distribution_issued_at) { Time.zone.local(2017, 1, 1) } + + it "returns the earliest year among created_at, purchases, and distributions" do + expect(organization.earliest_reporting_year).to eq(distribution_issued_at.year) + end + end + + describe "when donations, purchases, and distributions all exist" do + let!(:donation) { create(:donation, organization: organization, issued_at: donation_issued_at) } + let!(:purchase) { create(:purchase, organization: organization, issued_at: purchase_issued_at) } + let!(:distribution) { create(:distribution, organization: organization, issued_at: distribution_issued_at) } + let(:donation_issued_at) { Time.zone.local(2019, 1, 1) } + let(:purchase_issued_at) { Time.zone.local(2018, 1, 1) } + let(:distribution_issued_at) { Time.zone.local(2017, 1, 1) } + + it "returns the earliest year among created_at, donations, purchases, and distributions" do + expect(organization.earliest_reporting_year).to eq(distribution_issued_at.year) + end + end +end +describe '#display_last_distribution_date', :phoenix do + let(:organization) { create(:organization) } + + context 'when there are no distributions' do + it 'returns "No distributions"' do + expect(organization.display_last_distribution_date).to eq('No distributions') + end + end + + context 'when distributions exist' do + let!(:distribution) { create(:distribution, organization: organization, issued_at: 1.day.ago) } + + it 'returns the issued_at date of the most recent distribution' do + expect(organization.display_last_distribution_date).to eq(distribution.issued_at.strftime("%F")) + end + end +end +describe '#correct_logo_mime_type', :phoenix do + let(:organization_with_no_logo) { build(:organization, logo: nil) } + let(:organization_with_valid_logo) { build(:organization, logo: Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/logo.jpg'), 'image/jpeg')) } + let(:organization_with_invalid_logo) { build(:organization, logo: Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/logo.txt'), 'text/plain')) } + + context 'when logo is not attached' do + it 'does not add any errors' do + organization_with_no_logo.correct_logo_mime_type + expect(organization_with_no_logo.errors[:logo]).to be_empty + end + end + + context 'when logo is attached with valid content type' do + it 'does not add any errors' do + organization_with_valid_logo.correct_logo_mime_type + expect(organization_with_valid_logo.errors[:logo]).to be_empty + end + end + + context 'when logo is attached with invalid content type' do + it 'sets logo to nil' do + organization_with_invalid_logo.correct_logo_mime_type + expect(organization_with_invalid_logo.logo).to be_nil + end + + it 'adds an error message for invalid content type' do + organization_with_invalid_logo.correct_logo_mime_type + expect(organization_with_invalid_logo.errors[:logo]).to include('Must be a JPG or a PNG file') + end + end +end +describe '#some_request_type_enabled', :phoenix do + let(:organization) { build(:organization, enable_child_based_requests: child_based, enable_individual_requests: individual, enable_quantity_based_requests: quantity_based) } + + context 'when all request types are disabled' do + let(:child_based) { false } + let(:individual) { false } + let(:quantity_based) { false } + + it 'adds error for child-based requests' do + organization.some_request_type_enabled + expect(organization.errors[:enable_child_based_requests]).to include('You must allow at least one request type (child-based, individual, or quantity-based)') + end + + it 'adds error for individual requests' do + organization.some_request_type_enabled + expect(organization.errors[:enable_individual_requests]).to include('You must allow at least one request type (child-based, individual, or quantity-based)') + end + + it 'adds error for quantity-based requests' do + organization.some_request_type_enabled + expect(organization.errors[:enable_quantity_based_requests]).to include('You must allow at least one request type (child-based, individual, or quantity-based)') + end + end + + context 'when child-based requests are enabled' do + let(:child_based) { true } + let(:individual) { false } + let(:quantity_based) { false } + + it 'does not add errors' do + organization.some_request_type_enabled + expect(organization.errors).to be_empty + end + end + + context 'when individual requests are enabled' do + let(:child_based) { false } + let(:individual) { true } + let(:quantity_based) { false } + + it 'does not add errors' do + organization.some_request_type_enabled + expect(organization.errors).to be_empty + end + end + + context 'when quantity-based requests are enabled' do + let(:child_based) { false } + let(:individual) { false } + let(:quantity_based) { true } + + it 'does not add errors' do + organization.some_request_type_enabled + expect(organization.errors).to be_empty + end + end +end +describe '#get_admin_email', :phoenix do + let(:organization) { create(:organization) } + + context 'when there are no users with ORG_ADMIN role' do + it 'returns nil' do + expect(organization.get_admin_email).to be_nil + end + end + + context 'when multiple users have ORG_ADMIN role' do + let!(:admin_users) { create_list(:organization_admin, 3, organization: organization) } + + it 'returns the email of a randomly selected user' do + emails = admin_users.map(&:email) + expect(emails).to include(organization.get_admin_email) + end + end + + context 'when only one user has ORG_ADMIN role' do + let!(:admin_user) { create(:organization_admin, organization: organization) } + + it 'returns the email of the single user' do + expect(organization.get_admin_email).to eq(admin_user.email) + end + end + + context 'when an invalid role or organization is provided' do + it 'raises an error' do + allow(User).to receive(:with_role).and_raise(StandardError) + expect { organization.get_admin_email }.to raise_error(StandardError) + end + end +end +describe '#logo_size_check', :phoenix do + let(:organization_with_large_logo) do + build(:organization).tap do |org| + allow(org.logo).to receive(:byte_size).and_return(1.1.megabytes) + end + end + + let(:organization_with_exact_logo) do + build(:organization).tap do |org| + allow(org.logo).to receive(:byte_size).and_return(1.megabyte) + end + end + + let(:organization_with_small_logo) do + build(:organization).tap do |org| + allow(org.logo).to receive(:byte_size).and_return(0.9.megabytes) + end + end + + it 'adds an error when logo size is greater than 1 MB' do + organization_with_large_logo.logo_size_check + expect(organization_with_large_logo.errors[:logo]).to include('File size is greater than 1 MB') + end + + it 'does not add an error when logo size is exactly 1 MB' do + organization_with_exact_logo.logo_size_check + expect(organization_with_exact_logo.errors[:logo]).to be_empty + end + + it 'does not add an error when logo size is less than 1 MB' do + organization_with_small_logo.logo_size_check + expect(organization_with_small_logo.errors[:logo]).to be_empty + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/organization_stats_spec.rb b/phoenix-tests/unit/tests/app/models/organization_stats_spec.rb new file mode 100644 index 0000000000..2e6a84a8f9 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/organization_stats_spec.rb @@ -0,0 +1,128 @@ + +require "rails_helper" + +RSpec.describe OrganizationStats do +describe '#initialize', :phoenix do + let(:valid_organization) { build(:organization) } + let(:nil_organization) { nil } + let(:invalid_organization) { 'invalid_type' } + + it 'initializes with a valid organization' do + organization_stats = OrganizationStats.new(valid_organization) + expect(organization_stats.instance_variable_get(:@current_organization)).to eq(valid_organization) + end + + it 'initializes with nil organization' do + organization_stats = OrganizationStats.new(nil_organization) + expect(organization_stats.instance_variable_get(:@current_organization)).to be_nil + end + + it 'initializes with an invalid type' do + organization_stats = OrganizationStats.new(invalid_organization) + expect(organization_stats.instance_variable_get(:@current_organization)).to eq(invalid_organization) + end +end +describe "#partners_added", :phoenix do + let(:organization_stats_with_nil_partners) { OrganizationStats.new(partners: nil) } + let(:organization_stats_with_empty_partners) { OrganizationStats.new(partners: []) } + let(:organization_stats_with_partners) { OrganizationStats.new(partners: [Partner.new, Partner.new]) } + + it "returns 0 when partners is nil" do + expect(organization_stats_with_nil_partners.partners_added).to eq(0) + end + + it "returns 0 when partners is an empty array" do + expect(organization_stats_with_empty_partners.partners_added).to eq(0) + end + + it "returns the number of partners when partners is an array with elements" do + expect(organization_stats_with_partners.partners_added).to eq(2) + end +end +describe '#storage_locations_added', :phoenix do + let(:organization_stats) { create(:organization_stats, storage_locations: storage_locations) } + + context 'when storage_locations is nil' do + let(:storage_locations) { nil } + + it 'returns 0' do + expect(organization_stats.storage_locations_added).to eq(0) + end + end + + context 'when storage_locations is an empty array' do + let(:storage_locations) { [] } + + it 'returns 0' do + expect(organization_stats.storage_locations_added).to eq(0) + end + end + + context 'when storage_locations is not empty' do + let(:storage_locations) { build_list(:storage_location, 3) } + + it 'returns the number of storage locations' do + expect(organization_stats.storage_locations_added).to eq(3) + end + end +end +describe '#donation_sites_added', :phoenix do + let(:organization_stats_with_nil_sites) { OrganizationStats.new(donation_sites: nil) } + let(:organization_stats_with_empty_sites) { OrganizationStats.new(donation_sites: []) } + let(:organization_stats_with_sites) { OrganizationStats.new(donation_sites: [1, 2, 3]) } + + it 'returns 0 when donation_sites is nil' do + expect(organization_stats_with_nil_sites.donation_sites_added).to eq(0) + end + + it 'returns 0 when donation_sites is an empty array' do + expect(organization_stats_with_empty_sites.donation_sites_added).to eq(0) + end + + it 'returns the number of elements when donation_sites is not empty' do + expect(organization_stats_with_sites.donation_sites_added).to eq(3) + end +end +describe '#locations_with_inventory', :phoenix do + let(:organization) { create(:organization) } + let(:inventory) { View::Inventory.new(organization.id) } + + context 'when storage_locations is nil' do + let(:storage_locations) { nil } + + it 'returns an empty array' do + allow_any_instance_of(OrganizationStats).to receive(:storage_locations).and_return(storage_locations) + expect(OrganizationStats.new.locations_with_inventory).to eq([]) + end + end + + context 'when storage_locations is empty' do + let(:storage_locations) { [] } + + it 'returns an empty array' do + allow_any_instance_of(OrganizationStats).to receive(:storage_locations).and_return(storage_locations) + expect(OrganizationStats.new.locations_with_inventory).to eq([]) + end + end + + context 'when locations have positive inventory quantity' do + let(:storage_locations) { build_list(:storage_location, 2, :with_items, organization: organization) } + + it 'returns locations with positive inventory quantity' do + allow_any_instance_of(OrganizationStats).to receive(:storage_locations).and_return(storage_locations) + allow(inventory).to receive(:quantity_for).and_return(10) + expect(OrganizationStats.new.locations_with_inventory).to match_array(storage_locations) + end + end + + context 'when no locations have positive inventory quantity' do + let(:storage_locations) { build_list(:storage_location, 2, organization: organization) } + + it 'returns an empty array' do + allow_any_instance_of(OrganizationStats).to receive(:storage_locations).and_return(storage_locations) + allow(inventory).to receive(:quantity_for).and_return(0) + expect(OrganizationStats.new.locations_with_inventory).to eq([]) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/partner_group_spec.rb b/phoenix-tests/unit/tests/app/models/partner_group_spec.rb new file mode 100644 index 0000000000..f1f0ed4b67 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/partner_group_spec.rb @@ -0,0 +1,6 @@ + +require "rails_helper" + +RSpec.describe PartnerGroup do + +end diff --git a/phoenix-tests/unit/tests/app/models/partner_spec.rb b/phoenix-tests/unit/tests/app/models/partner_spec.rb new file mode 100644 index 0000000000..f1dba34447 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/partner_spec.rb @@ -0,0 +1,1182 @@ + +require "rails_helper" + +RSpec.describe Partner do +describe '#display_status', :phoenix do + let(:partner_awaiting_review) { build(:partner, :awaiting_review) } + let(:partner_uninvited) { build(:partner, :uninvited) } + let(:partner_approved) { build(:partner, :approved) } + let(:partner_other_status) { build(:partner, :deactivated) } + + it 'returns Submitted when status is :awaiting_review' do + expect(partner_awaiting_review.display_status).to eq('Submitted') + end + + it 'returns Pending when status is :uninvited' do + expect(partner_uninvited.display_status).to eq('Pending') + end + + it 'returns Verified when status is :approved' do + expect(partner_approved.display_status).to eq('Verified') + end + + it 'returns titleized status for any other status' do + expect(partner_other_status.display_status).to eq('Deactivated') + end +end +describe '#primary_user', :phoenix do + let(:partner) { create(:partner) } + + context 'when there are no users' do + it 'returns nil' do + expect(partner.primary_user).to be_nil + end + end + + context 'when there is only one user' do + let!(:user) { create(:partner_user, partner: partner) } + + it 'returns the user' do + expect(partner.primary_user).to eq(user) + end + end + + context 'when there are multiple users' do + let!(:user1) { create(:partner_user, partner: partner, created_at: 2.days.ago) } + let!(:user2) { create(:partner_user, partner: partner, created_at: 1.day.ago) } + + it 'returns the earliest created user' do + expect(partner.primary_user).to eq(user1) + end + end + + context 'when users have the same creation date' do + let!(:user1) { create(:partner_user, partner: partner, created_at: 1.day.ago) } + let!(:user2) { create(:partner_user, partner: partner, created_at: 1.day.ago) } + + it 'returns one of the users' do + expect([user1, user2]).to include(partner.primary_user) + end + end +end +describe '#deletable?', :phoenix do + let(:partner) { build(:partner, :uninvited) } + let(:distribution) { build(:distribution) } + let(:request) { build(:request) } + let(:user) { build(:partner_user, partner: partner) } + + it 'returns true when uninvited and has no distributions, requests, or users' do + allow(partner).to receive(:distributions).and_return([]) + allow(partner).to receive(:requests).and_return([]) + allow(partner).to receive(:users).and_return([]) + expect(partner.deletable?).to be true + end + + context 'when uninvited? is false' do + let(:partner) { build(:partner, status: :approved) } + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when distributions are present' do + before { allow(partner).to receive(:distributions).and_return([distribution]) } + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when requests are present' do + before { allow(partner).to receive(:requests).and_return([request]) } + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when users are present' do + before { allow(partner).to receive(:users).and_return([user]) } + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when uninvited? and distributions are present' do + let(:partner) { build(:partner, status: :approved) } + before { allow(partner).to receive(:distributions).and_return([distribution]) } + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when distributions and requests are present' do + before do + allow(partner).to receive(:distributions).and_return([distribution]) + allow(partner).to receive(:requests).and_return([request]) + end + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when requests and users are present' do + before do + allow(partner).to receive(:requests).and_return([request]) + allow(partner).to receive(:users).and_return([user]) + end + + it 'returns false' do + expect(partner.deletable?).to be false + end + end + + context 'when users and distributions are present' do + before do + allow(partner).to receive(:users).and_return([user]) + allow(partner).to receive(:distributions).and_return([distribution]) + end + + it 'returns false' do + expect(partner.deletable?).to be false + end + end +end +describe '#approvable?', :phoenix do + let(:partner_invited) { build(:partner, status: :invited) } + let(:partner_awaiting_review) { build(:partner, status: :awaiting_review) } + let(:partner_uninvited) { build(:partner, status: :uninvited) } + let(:partner_approved) { build(:partner, status: :approved) } + let(:partner_error) { build(:partner, status: :error) } + let(:partner_recertification_required) { build(:partner, status: :recertification_required) } + let(:partner_deactivated) { build(:partner, status: :deactivated) } + + it 'returns true when status is invited' do + expect(partner_invited.approvable?).to eq(true) + end + + it 'returns true when status is awaiting_review' do + expect(partner_awaiting_review.approvable?).to eq(true) + end + + it 'returns false when status is uninvited' do + expect(partner_uninvited.approvable?).to eq(false) + end + + it 'returns false when status is approved' do + expect(partner_approved.approvable?).to eq(false) + end + + it 'returns false when status is error' do + expect(partner_error.approvable?).to eq(false) + end + + it 'returns false when status is recertification_required' do + expect(partner_recertification_required.approvable?).to eq(false) + end + + it 'returns false when status is deactivated' do + expect(partner_deactivated.approvable?).to eq(false) + end +end +describe '#import_csv', :phoenix do + let(:organization) { create(:organization) } + let(:partner_attributes) { { 'name' => 'Test Partner', 'email' => 'test@example.com' } } + let(:csv) { [partner_attributes] } + + context 'when CSV is valid' do + it 'successfully imports a row' do + expect { + Partner.import_csv(csv, organization.id) + }.to change { organization.partners.count }.by(1) + end + end + + context 'when there are errors during import' do + before do + allow_any_instance_of(PartnerCreateService).to receive(:call).and_return(double(errors: ['Error message'])) + end + + it 'returns errors' do + errors = Partner.import_csv(csv, organization.id) + expect(errors).to include('Test Partner: Error message') + end + end + + context 'when CSV is empty' do + let(:csv) { [] } + + it 'does not create any partners' do + expect { + Partner.import_csv(csv, organization.id) + }.not_to change { organization.partners.count } + end + end + + context 'when organization is not found' do + it 'raises an ActiveRecord::RecordNotFound error' do + expect { + Partner.import_csv(csv, -1) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when CSV contains invalid data' do + let(:partner_attributes) { { 'name' => '', 'email' => 'invalid_email' } } + + it 'returns validation errors' do + errors = Partner.import_csv(csv, organization.id) + expect(errors).to include("Test Partner: Name can't be blank, Email is invalid") + end + end +end +describe '.csv_export_headers', :phoenix do + it 'returns the correct CSV headers' do + expected_headers = [ + "Agency Name", + "Agency Email", + "Agency Address", + "Agency City", + "Agency State", + "Agency Zip Code", + "Agency Website", + "Agency Type", + "Contact Name", + "Contact Phone", + "Contact Email", + "Notes" + ] + expect(Partner.csv_export_headers).to eq(expected_headers) + end +end +describe "#csv_export_attributes", :phoenix do + let(:partner) { build(:partner, name: "Partner Name", email: "partner@example.com", agency_info: agency_info, contact_person: contact_person, notes: "Some notes") } + let(:agency_info) { { address: "123 Main St", city: "Metropolis", state: "NY", zip_code: "12345", website: "http://example.com", agency_type: "Non-Profit" } } + let(:contact_person) { { name: "John Doe", phone: "555-1234", email: "john.doe@example.com" } } + + it "returns all attributes when all data is present" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + "http://example.com", + "Non-Profit", + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + + describe "when agency_info is nil" do + let(:agency_info) { nil } + + it "returns nil for all agency_info fields" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + nil, + nil, + nil, + nil, + nil, + nil, + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + describe "when contact_person is nil" do + let(:contact_person) { nil } + + it "returns nil for all contact_person fields" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + "http://example.com", + "Non-Profit", + nil, + nil, + nil, + "Some notes" + ]) + end + end + + describe "when agency_info is missing keys" do + context "missing address" do + let(:agency_info) { { city: "Metropolis", state: "NY", zip_code: "12345", website: "http://example.com", agency_type: "Non-Profit" } } + + it "returns nil for address" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + nil, + "Metropolis", + "NY", + "12345", + "http://example.com", + "Non-Profit", + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing city" do + let(:agency_info) { { address: "123 Main St", state: "NY", zip_code: "12345", website: "http://example.com", agency_type: "Non-Profit" } } + + it "returns nil for city" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + nil, + "NY", + "12345", + "http://example.com", + "Non-Profit", + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing state" do + let(:agency_info) { { address: "123 Main St", city: "Metropolis", zip_code: "12345", website: "http://example.com", agency_type: "Non-Profit" } } + + it "returns nil for state" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + nil, + "12345", + "http://example.com", + "Non-Profit", + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing zip_code" do + let(:agency_info) { { address: "123 Main St", city: "Metropolis", state: "NY", website: "http://example.com", agency_type: "Non-Profit" } } + + it "returns nil for zip_code" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + nil, + "http://example.com", + "Non-Profit", + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing website" do + let(:agency_info) { { address: "123 Main St", city: "Metropolis", state: "NY", zip_code: "12345", agency_type: "Non-Profit" } } + + it "returns nil for website" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + nil, + "Non-Profit", + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing agency_type" do + let(:agency_info) { { address: "123 Main St", city: "Metropolis", state: "NY", zip_code: "12345", website: "http://example.com" } } + + it "returns nil for agency_type" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + "http://example.com", + nil, + "John Doe", + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + end + + describe "when contact_person is missing keys" do + context "missing name" do + let(:contact_person) { { phone: "555-1234", email: "john.doe@example.com" } } + + it "returns nil for name" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + "http://example.com", + "Non-Profit", + nil, + "555-1234", + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing phone" do + let(:contact_person) { { name: "John Doe", email: "john.doe@example.com" } } + + it "returns nil for phone" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + "http://example.com", + "Non-Profit", + "John Doe", + nil, + "john.doe@example.com", + "Some notes" + ]) + end + end + + context "missing email" do + let(:contact_person) { { name: "John Doe", phone: "555-1234" } } + + it "returns nil for email" do + expect(partner.csv_export_attributes).to eq([ + "Partner Name", + "partner@example.com", + "123 Main St", + "Metropolis", + "NY", + "12345", + "http://example.com", + "Non-Profit", + "John Doe", + "555-1234", + nil, + "Some notes" + ]) + end + end + end +end +describe '#contact_person', :phoenix do + let(:partner) { build(:partner) } + let(:profile) { build(:partner_profile, partner: partner) } + + before do + allow(partner).to receive(:profile).and_return(profile) + end + + it 'returns @contact_person if already set' do + contact_person = { name: 'John Doe', email: 'john@example.com', phone: '123-456-7890' } + partner.instance_variable_set(:@contact_person, contact_person) + expect(partner.contact_person).to eq(contact_person) + end + + context 'when profile is blank' do + let(:profile) { nil } + + it 'returns an empty hash' do + expect(partner.contact_person).to eq({}) + end + end + + context 'when profile is not blank' do + before do + allow(profile).to receive(:primary_contact_name).and_return('John Doe') + allow(profile).to receive(:primary_contact_email).and_return('john@example.com') + end + + it 'returns a hash with name, email, and phone' do + allow(profile).to receive(:primary_contact_phone).and_return('123-456-7890') + expect(partner.contact_person).to eq({ + name: 'John Doe', + email: 'john@example.com', + phone: '123-456-7890' + }) + end + + context 'when primary_contact_phone is present' do + before do + allow(profile).to receive(:primary_contact_phone).and_return('123-456-7890') + end + + it 'sets phone to primary_contact_phone' do + expect(partner.contact_person[:phone]).to eq('123-456-7890') + end + end + + context 'when primary_contact_phone is not present' do + before do + allow(profile).to receive(:primary_contact_phone).and_return(nil) + allow(profile).to receive(:primary_contact_mobile).and_return('098-765-4321') + end + + it 'sets phone to primary_contact_mobile' do + expect(partner.contact_person[:phone]).to eq('098-765-4321') + end + end + end +end +describe '#agency_info', :phoenix do + let(:partner) { build(:partner) } + let(:profile) { build(:partner_profile, partner: partner) } + + before do + allow(partner).to receive(:profile).and_return(profile) + end + + it 'returns cached @agency_info if already set' do + partner.instance_variable_set(:@agency_info, { cached: 'info' }) + expect(partner.agency_info).to eq({ cached: 'info' }) + end + + context 'when profile is blank' do + let(:profile) { nil } + + it 'returns an empty hash' do + expect(partner.agency_info).to eq({}) + end + end + + context 'when profile is present' do + it 'constructs @agency_info with address' do + expected_address = [profile.address1, profile.address2].select(&:present?).join(', ') + expect(partner.agency_info[:address]).to eq(expected_address) + end + + it 'includes city' do + expect(partner.agency_info[:city]).to eq(profile.city) + end + + it 'includes state' do + expect(partner.agency_info[:state]).to eq(profile.state) + end + + it 'includes zip code' do + expect(partner.agency_info[:zip_code]).to eq(profile.zip_code) + end + + it 'includes website' do + expect(partner.agency_info[:website]).to eq(profile.website) + end + + context 'when agency_type is OTHER' do + let(:profile) { build(:partner_profile, partner: partner, agency_type: AGENCY_TYPES['OTHER'], other_agency_type: 'Special Type') } + + it 'appends other_agency_type to agency_type' do + expect(partner.agency_info[:agency_type]).to eq("#{AGENCY_TYPES['OTHER']}: Special Type") + end + end + + context 'when agency_type is not OTHER' do + let(:profile) { build(:partner_profile, partner: partner, agency_type: 'Regular Type') } + + it 'uses the given agency_type' do + expect(partner.agency_info[:agency_type]).to eq('Regular Type') + end + end + end +end +describe '#partials_to_show', :phoenix do + let(:organization) { create(:organization) } + let(:partner) { build(:partner, organization: organization) } + + context 'when partner_form_fields are present' do + before do + allow(organization).to receive(:partner_form_fields).and_return(['field1', 'field2']) + end + + it 'returns partner_form_fields' do + expect(partner.partials_to_show).to eq(['field1', 'field2']) + end + end + + context 'when partner_form_fields are not present' do + before do + allow(organization).to receive(:partner_form_fields).and_return(nil) + end + + it 'returns ALL_PARTIALS' do + expect(partner.partials_to_show).to eq(ALL_PARTIALS) + end + end +end +describe '#quantity_year_to_date', :phoenix do + let(:partner) { create(:partner) } + let(:organization) { partner.organization } + let(:item) { create(:item, organization: organization) } + let(:storage_location) { create(:storage_location, :with_items, item: item, organization: organization) } + + let(:distribution_this_year_with_items) do + create(:distribution, :with_items, item: item, item_quantity: 10, partner: partner, storage_location: storage_location, issued_at: Time.zone.today.beginning_of_year + 1.day) + end + + let(:distribution_this_year_without_items) do + create(:distribution, partner: partner, storage_location: storage_location, issued_at: Time.zone.today.beginning_of_year + 1.day) + end + + let(:distribution_last_year_with_items) do + create(:distribution, :with_items, item: item, item_quantity: 5, partner: partner, storage_location: storage_location, issued_at: Time.zone.today.beginning_of_year - 1.day) + end + + let(:distribution_exactly_beginning_of_year) do + create(:distribution, :with_items, item: item, item_quantity: 15, partner: partner, storage_location: storage_location, issued_at: Time.zone.today.beginning_of_year) + end + + it 'calculates the sum of quantities for distributions issued from the beginning of the year' do + distribution_this_year_with_items + expect(partner.quantity_year_to_date).to eq(10) + end + + it 'returns zero when there are no distributions issued from the beginning of the year' do + expect(partner.quantity_year_to_date).to eq(0) + end + + it 'returns zero when there are distributions but none with line items' do + distribution_this_year_without_items + expect(partner.quantity_year_to_date).to eq(0) + end + + it 'returns zero when all distributions with line items are issued before the beginning of the year' do + distribution_last_year_with_items + expect(partner.quantity_year_to_date).to eq(0) + end + + it 'includes distributions issued exactly at the beginning of the year' do + distribution_exactly_beginning_of_year + expect(partner.quantity_year_to_date).to eq(15) + end +end +describe '#impact_metrics', :phoenix do + let(:partner) { create(:partner) } + let(:families) { build_list(:partners_family, 3, partner: partner) } + let(:children) { build_list(:partners_child, 5, family: families.first) } + let(:zipcodes) { families.map(&:guardian_zip_code).uniq } + + before do + allow(partner).to receive(:families_served_count).and_return(families.size) + allow(partner).to receive(:children_served_count).and_return(children.size) + allow(partner).to receive(:family_zipcodes_count).and_return(zipcodes.size) + allow(partner).to receive(:family_zipcodes_list).and_return(zipcodes) + end + + it 'returns a hash with the correct keys' do + expect(partner.impact_metrics.keys).to contain_exactly(:families_served, :children_served, :family_zipcodes, :family_zipcodes_list) + end + + it 'returns the correct families_served count' do + expect(partner.impact_metrics[:families_served]).to eq(families.size) + end + + it 'returns the correct children_served count' do + expect(partner.impact_metrics[:children_served]).to eq(children.size) + end + + it 'returns the correct family_zipcodes count' do + expect(partner.impact_metrics[:family_zipcodes]).to eq(zipcodes.size) + end + + it 'returns the correct family_zipcodes list' do + expect(partner.impact_metrics[:family_zipcodes_list]).to match_array(zipcodes) + end + + describe 'when families_served_count returns an unexpected value' do + before do + allow(partner).to receive(:families_served_count).and_return(-1) + end + + it 'handles the unexpected value gracefully' do + expect(partner.impact_metrics[:families_served]).to eq(-1) + end + end + + describe 'when children_served_count returns an unexpected value' do + before do + allow(partner).to receive(:children_served_count).and_return(-1) + end + + it 'handles the unexpected value gracefully' do + expect(partner.impact_metrics[:children_served]).to eq(-1) + end + end + + describe 'when family_zipcodes_count returns an unexpected value' do + before do + allow(partner).to receive(:family_zipcodes_count).and_return(-1) + end + + it 'handles the unexpected value gracefully' do + expect(partner.impact_metrics[:family_zipcodes]).to eq(-1) + end + end + + describe 'when family_zipcodes_list returns an unexpected value' do + before do + allow(partner).to receive(:family_zipcodes_list).and_return(['unexpected']) + end + + it 'handles the unexpected value gracefully' do + expect(partner.impact_metrics[:family_zipcodes_list]).to eq(['unexpected']) + end + end +end +describe '#quota_exceeded?', :phoenix do + let(:partner_without_quota) { build(:partner, quota: nil) } + let(:partner_with_quota) { build(:partner, quota: 100) } + + it 'returns false when quota is not present' do + expect(partner_without_quota.quota_exceeded?(50)).to be false + end + + context 'when quota is present' do + it 'returns false when total is equal to quota' do + expect(partner_with_quota.quota_exceeded?(100)).to be false + end + + it 'returns false when total is less than quota' do + expect(partner_with_quota.quota_exceeded?(50)).to be false + end + + it 'returns true when total is greater than quota' do + expect(partner_with_quota.quota_exceeded?(150)).to be true + end + end +end +describe '#families_served_count', :phoenix do + let(:partner) { build_stubbed(:partner) } + + context 'when there are no families' do + it 'returns 0' do + expect(partner.families_served_count).to eq(0) + end + end + + context 'when there is one family' do + before do + allow(partner).to receive(:families).and_return([build_stubbed(:partners_family, partner: partner)]) + end + + it 'returns 1' do + expect(partner.families_served_count).to eq(1) + end + end + + context 'when there are multiple families' do + before do + allow(partner).to receive(:families).and_return(build_stubbed_list(:partners_family, 3, partner: partner)) + end + + it 'returns the correct count' do + expect(partner.families_served_count).to eq(3) + end + end +end +describe '#children_served_count', :phoenix do + let(:partner) { create(:partner) } + + context 'when there are no children' do + it 'returns 0' do + expect(partner.children_served_count).to eq(0) + end + end + + context 'when there is one child' do + let!(:child) { create(:partners_child, family: create(:partners_family, partner: partner)) } + + it 'returns 1' do + expect(partner.children_served_count).to eq(1) + end + end + + context 'when there are multiple children' do + let!(:children) { create_list(:partners_child, 3, family: create(:partners_family, partner: partner)) } + + it 'returns the correct count' do + expect(partner.children_served_count).to eq(3) + end + end + + context 'when children association is nil' do + before do + allow(partner).to receive(:children).and_return(nil) + end + + it 'returns 0' do + expect(partner.children_served_count).to eq(0) + end + end +end +describe '#family_zipcodes_count', :phoenix do + let(:partner) { create(:partner) } + + context 'when there are no families' do + it 'returns 0' do + expect(partner.family_zipcodes_count).to eq(0) + end + end + + context 'when all zip codes are unique' do + let!(:families) do + build_list(:partners_family, 3, partner: partner, guardian_zip_code: -> { Faker::Address.unique.zip }) + end + + it 'returns the count of unique zip codes' do + expect(partner.family_zipcodes_count).to eq(3) + end + end + + context 'when there are duplicate zip codes' do + let!(:families) do + build_list(:partners_family, 3, partner: partner, guardian_zip_code: '12345') + end + + it 'returns the count of unique zip codes' do + expect(partner.family_zipcodes_count).to eq(1) + end + end + + context 'for a mix of unique and duplicate zip codes' do + let!(:families) do + [ + build(:partners_family, partner: partner, guardian_zip_code: '12345'), + build(:partners_family, partner: partner, guardian_zip_code: '12345'), + build(:partners_family, partner: partner, guardian_zip_code: '67890') + ] + end + + it 'returns the count of unique zip codes' do + expect(partner.family_zipcodes_count).to eq(2) + end + end + + context 'when handling nil or blank zip codes' do + let!(:families) do + [ + build(:partners_family, partner: partner, guardian_zip_code: nil), + build(:partners_family, partner: partner, guardian_zip_code: ''), + build(:partners_family, partner: partner, guardian_zip_code: '12345') + ] + end + + it 'ignores nil or blank zip codes and counts unique zip codes' do + expect(partner.family_zipcodes_count).to eq(1) + end + end +end +describe '#family_zipcodes_list', :phoenix do + let(:partner) { create(:partner) } + + context 'when there are no families' do + it 'returns an empty list' do + expect(partner.family_zipcodes_list).to eq([]) + end + end + + context 'when there are duplicate zip codes' do + let!(:family1) { create(:partners_family, partner: partner, guardian_zip_code: '12345') } + let!(:family2) { create(:partners_family, partner: partner, guardian_zip_code: '12345') } + + it 'returns unique zip codes' do + expect(partner.family_zipcodes_list).to eq(['12345']) + end + end + + context 'when all zip codes are unique' do + let!(:family1) { create(:partners_family, partner: partner, guardian_zip_code: '12345') } + let!(:family2) { create(:partners_family, partner: partner, guardian_zip_code: '67890') } + + it 'returns all zip codes' do + expect(partner.family_zipcodes_list).to match_array(['12345', '67890']) + end + end + + context 'when there are nil or empty zip codes' do + let!(:family1) { create(:partners_family, partner: partner, guardian_zip_code: nil) } + let!(:family2) { create(:partners_family, partner: partner, guardian_zip_code: '') } + let!(:family3) { create(:partners_family, partner: partner, guardian_zip_code: '12345') } + + it 'handles nil or empty zip codes gracefully' do + expect(partner.family_zipcodes_list).to eq(['12345']) + end + end + + context 'with a large number of families' do + before do + create_list(:partners_family, 100, partner: partner, guardian_zip_code: '12345') + create_list(:partners_family, 100, partner: partner, guardian_zip_code: '67890') + end + + it 'performs well with a large number of families' do + expect(partner.family_zipcodes_list).to match_array(['12345', '67890']) + end + end +end +describe '#correct_document_mime_type', :phoenix do + let(:partner) { build(:partner) } + let(:allowed_mime_type) { 'application/pdf' } + let(:disallowed_mime_type) { 'image/png' } + + context 'when no documents are attached' do + before do + allow(partner.documents).to receive(:attached?).and_return(false) + end + + it 'does not add errors' do + partner.correct_document_mime_type + expect(partner.errors[:documents]).to be_empty + end + end + + context 'when all documents have allowed MIME types' do + before do + allow(partner.documents).to receive(:attached?).and_return(true) + allow(partner.documents).to receive(:any?).and_return(false) + end + + it 'does not add errors' do + partner.correct_document_mime_type + expect(partner.errors[:documents]).to be_empty + end + end + + context 'when at least one document has a disallowed MIME type' do + before do + allow(partner.documents).to receive(:attached?).and_return(true) + allow(partner.documents).to receive(:any?).and_return(true) + end + + it 'adds an error' do + partner.correct_document_mime_type + expect(partner.errors[:documents]).to include('Must be a PDF or DOC file') + end + end +end +describe '#invite_new_partner', :phoenix do + let(:partner) { build(:partner) } + let(:user) { build(:user, email: partner.email) } + let(:role_partner) { Role::PARTNER } + + context 'when the user does not exist' do + it 'sends an invitation' do + allow(User).to receive(:find_by).with(email: partner.email).and_return(nil) + expect(UserInviteService).to receive(:invite).with(email: partner.email, roles: [role_partner], resource: partner) + partner.invite_new_partner + end + end + + context 'when the user exists without all roles' do + it 'adds roles and sends an invitation' do + allow(User).to receive(:find_by).with(email: partner.email).and_return(user) + allow(user).to receive(:has_role?).with(role_partner, partner).and_return(false) + expect(UserInviteService).to receive(:invite).with(email: partner.email, roles: [role_partner], resource: partner) + partner.invite_new_partner + end + end + + context 'when the user exists with some roles and force is true' do + it 'adds roles and sends an invitation' do + allow(User).to receive(:find_by).with(email: partner.email).and_return(user) + allow(user).to receive(:has_role?).with(role_partner, partner).and_return(false) + expect(UserInviteService).to receive(:invite).with(email: partner.email, roles: [role_partner], resource: partner) + partner.invite_new_partner + end + end + + context 'when resource is nil' do + it 'raises an exception' do + allow(partner).to receive(:invite_new_partner).and_raise('Resource not found!') + expect { partner.invite_new_partner }.to raise_error('Resource not found!') + end + end + + context 'when the user already has all roles' do + it 'raises an exception' do + allow(User).to receive(:find_by).with(email: partner.email).and_return(user) + allow(user).to receive(:has_role?).with(role_partner, partner).and_return(true) + expect { partner.invite_new_partner }.to raise_error('User already has the requested role!') + end + end + + context 'when email is invalid' do + it 'skips invitation' do + allow(UserInviteService).to receive(:invite).with(email: partner.email, roles: [role_partner], resource: partner).and_return(user) + allow(user).to receive(:errors).and_return(email: ['is invalid']) + expect(user).to receive(:skip_invitation=).with(true) + partner.invite_new_partner + end + end + + describe 'edge cases' do + context 'when name parameter is blank or nil' do + it 'handles blank or nil name parameter' do + allow(UserInviteService).to receive(:invite).with(email: partner.email, roles: [role_partner], resource: partner).and_yield(user) + allow(user).to receive(:name=).with(nil) + partner.invite_new_partner + end + end + + context 'when roles array is empty' do + it 'handles empty roles array' do + allow(UserInviteService).to receive(:invite).with(email: partner.email, roles: [], resource: partner).and_yield(user) + allow(user).to receive(:roles).and_return([]) + partner.invite_new_partner + end + end + + context 'when force flag toggled between true and false' do + it 'handles force flag toggled between true and false' do + allow(User).to receive(:find_by).with(email: partner.email).and_return(user) + allow(user).to receive(:has_role?).with(role_partner, partner).and_return(false) + expect(UserInviteService).to receive(:invite).with(email: partner.email, roles: [role_partner], resource: partner) + partner.invite_new_partner + end + end + end +end +describe '#should_invite_because_email_changed?', :phoenix do + let(:partner) { build(:partner, email: 'new_email@example.com') } + let(:existing_partner_user) { create(:partner_user, email: 'existing_email@example.com') } + + context 'when email has changed and partner is invited, with no existing partner user with the same email' do + let(:partner) { build(:partner, :invited, email: 'new_email@example.com') } + + it 'returns true when email has changed and partner is invited' do + allow(partner).to receive(:email_changed?).and_return(true) + allow(partner).to receive(:invited?).and_return(true) + allow(partner).to receive(:partner_user_with_same_email_exist?).and_return(false) + expect(partner.should_invite_because_email_changed?).to eq(true) + end + end + + context 'when email has changed and partner is awaiting review, with no existing partner user with the same email' do + let(:partner) { build(:partner, :awaiting_review, email: 'new_email@example.com') } + + it 'returns true when email has changed and partner is awaiting review' do + allow(partner).to receive(:email_changed?).and_return(true) + allow(partner).to receive(:awaiting_review?).and_return(true) + allow(partner).to receive(:partner_user_with_same_email_exist?).and_return(false) + expect(partner.should_invite_because_email_changed?).to eq(true) + end + end + + context 'when email has changed and recertification is required, with no existing partner user with the same email' do + let(:partner) { build(:partner, :recertification_required, email: 'new_email@example.com') } + + it 'returns true when email has changed and recertification is required' do + allow(partner).to receive(:email_changed?).and_return(true) + allow(partner).to receive(:recertification_required?).and_return(true) + allow(partner).to receive(:partner_user_with_same_email_exist?).and_return(false) + expect(partner.should_invite_because_email_changed?).to eq(true) + end + end + + context 'when email has changed and partner is approved, with no existing partner user with the same email' do + let(:partner) { build(:partner, :approved, email: 'new_email@example.com') } + + it 'returns true when email has changed and partner is approved' do + allow(partner).to receive(:email_changed?).and_return(true) + allow(partner).to receive(:approved?).and_return(true) + allow(partner).to receive(:partner_user_with_same_email_exist?).and_return(false) + expect(partner.should_invite_because_email_changed?).to eq(true) + end + end + + context 'when email has changed and one condition is true, but a partner user with the same email exists' do + let(:partner) { build(:partner, :invited, email: existing_partner_user.email) } + + it 'returns false when a partner user with the same email exists' do + allow(partner).to receive(:email_changed?).and_return(true) + allow(partner).to receive(:invited?).and_return(true) + allow(partner).to receive(:partner_user_with_same_email_exist?).and_return(true) + expect(partner.should_invite_because_email_changed?).to eq(false) + end + end + + context 'when email has not changed' do + let(:partner) { build(:partner, email: 'existing_email@example.com') } + + it 'returns false when email has not changed' do + allow(partner).to receive(:email_changed?).and_return(false) + expect(partner.should_invite_because_email_changed?).to eq(false) + end + end +end +describe '#partner_user_with_same_email_exist?', :phoenix do + let(:partner) { create(:partner) } + let(:email) { 'test@example.com' } + + context 'when a user with the same email exists and has the partner role' do + let!(:user_with_partner_role) { create(:partner_user, email: email, partner: partner) } + + it 'returns true' do + expect(partner.partner_user_with_same_email_exist?).to be true + end + end + + context 'when a user with the same email exists but does not have the partner role' do + let!(:user_without_partner_role) { create(:user, email: email) } + + it 'returns false' do + expect(partner.partner_user_with_same_email_exist?).to be false + end + end + + context 'when no user with the same email exists' do + it 'returns false' do + expect(partner.partner_user_with_same_email_exist?).to be false + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/product_drive_participant_spec.rb b/phoenix-tests/unit/tests/app/models/product_drive_participant_spec.rb new file mode 100644 index 0000000000..f73a31bcf4 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/product_drive_participant_spec.rb @@ -0,0 +1,101 @@ + +require "rails_helper" + +RSpec.describe ProductDriveParticipant do +describe "#volume", :phoenix do + let(:product_drive_participant) { create(:product_drive_participant) } + + context "when there are no donations" do + it "returns zero" do + expect(product_drive_participant.volume).to eq(0) + end + end + + context "when donations have no line items" do + let!(:donations) { create_list(:donation, 3, product_drive_participant: product_drive_participant) } + + it "returns zero" do + expect(product_drive_participant.volume).to eq(0) + end + end + + context "when line items have zero total" do + let!(:donations) { create_list(:donation, 3, :with_items, item_quantity: 0, product_drive_participant: product_drive_participant) } + + it "returns zero" do + expect(product_drive_participant.volume).to eq(0) + end + end + + context "when line items have positive totals" do + let!(:donations) { create_list(:donation, 3, :with_items, item_quantity: 10, product_drive_participant: product_drive_participant) } + + it "calculates the total volume correctly" do + expect(product_drive_participant.volume).to eq(30) + end + end + + context "when line items have negative totals" do + let!(:donations) { create_list(:donation, 3, :with_items, item_quantity: -5, product_drive_participant: product_drive_participant) } + + it "calculates the total volume correctly with negative totals" do + expect(product_drive_participant.volume).to eq(-15) + end + end + + context "for multiple donations with various line item totals" do + let!(:donation1) { create(:donation, :with_items, item_quantity: 10, product_drive_participant: product_drive_participant) } + let!(:donation2) { create(:donation, :with_items, item_quantity: -5, product_drive_participant: product_drive_participant) } + let!(:donation3) { create(:donation, :with_items, item_quantity: 0, product_drive_participant: product_drive_participant) } + + it "calculates the total volume correctly for mixed totals" do + expect(product_drive_participant.volume).to eq(5) + end + end +end +describe "#volume_by_product_drive", :phoenix do + let(:product_drive) { create(:product_drive) } + let(:product_drive_participant) { create(:product_drive_participant) } + + context "when there are no donations for the given product_drive_id" do + it "returns 0" do + expect(product_drive_participant.volume_by_product_drive(product_drive.id)).to eq(0) + end + end + + context "when there are donations with the given product_drive_id" do + let!(:donation_with_items) { create(:product_drive_donation, :with_items, product_drive: product_drive, product_drive_participant: product_drive_participant) } + let!(:another_donation_with_items) { create(:product_drive_donation, :with_items, product_drive: product_drive, product_drive_participant: product_drive_participant) } + + it "calculates the total volume of line items for the product drive" do + total_quantity = donation_with_items.line_items.pluck(:quantity).sum + another_donation_with_items.line_items.pluck(:quantity).sum + expect(product_drive_participant.volume_by_product_drive(product_drive.id)).to eq(total_quantity) + end + end + + context "when the product_drive_id is invalid or does not exist" do + it "returns 0" do + expect(product_drive_participant.volume_by_product_drive(-1)).to eq(0) + end + end +end +describe "#donation_source_view", :phoenix do + let(:product_drive_participant) { build(:product_drive_participant, contact_name: contact_name) } + + context "when contact_name is blank" do + let(:contact_name) { "" } + + it "returns nil" do + expect(product_drive_participant.donation_source_view).to be_nil + end + end + + context "when contact_name is present" do + let(:contact_name) { "Don Draper" } + + it "returns the contact_name with (participant)" do + expect(product_drive_participant.donation_source_view).to eq("Don Draper (participant)") + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/product_drive_spec.rb b/phoenix-tests/unit/tests/app/models/product_drive_spec.rb new file mode 100644 index 0000000000..a75513fe00 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/product_drive_spec.rb @@ -0,0 +1,444 @@ + +require "rails_helper" + +RSpec.describe ProductDrive do +describe '#end_date_is_bigger_of_end_date', :phoenix do + let(:product_drive) { build(:product_drive, start_date: start_date, end_date: end_date) } + + context 'when both start_date and end_date are nil' do + let(:start_date) { nil } + let(:end_date) { nil } + + it 'does nothing' do + product_drive.valid? + expect(product_drive.errors[:end_date]).to be_empty + end + end + + context 'when start_date is nil and end_date is not nil' do + let(:start_date) { nil } + let(:end_date) { Date.today } + + it 'does nothing' do + product_drive.valid? + expect(product_drive.errors[:end_date]).to be_empty + end + end + + context 'when end_date is nil and start_date is not nil' do + let(:start_date) { Date.today } + let(:end_date) { nil } + + it 'does nothing' do + product_drive.valid? + expect(product_drive.errors[:end_date]).to be_empty + end + end + + context 'when end_date is greater than start_date' do + let(:start_date) { Date.today } + let(:end_date) { Date.tomorrow } + + it 'does nothing' do + product_drive.valid? + expect(product_drive.errors[:end_date]).to be_empty + end + end + + context 'when end_date is less than start_date' do + let(:start_date) { Date.tomorrow } + let(:end_date) { Date.today } + + it 'adds an error' do + product_drive.valid? + expect(product_drive.errors[:end_date]).to include('End date must be after the start date') + end + end +end +describe '#donation_quantity', :phoenix do + let(:organization) { create(:organization) } + let(:product_drive) { create(:product_drive, organization: organization) } + let(:donation_with_items) { build(:donation, :with_items, product_drive: product_drive, organization: organization, item_quantity: 5) } + let(:donation_without_items) { build(:donation, product_drive: product_drive, organization: organization) } + let(:large_quantity_donation) { build(:donation, :with_items, product_drive: product_drive, organization: organization, item_quantity: 1_000_000) } + let(:negative_quantity_donation) { build(:donation, :with_items, product_drive: product_drive, organization: organization, item_quantity: -5) } + + it 'sums the quantities of line items associated with donations' do + donation_with_items.save + expect(product_drive.donation_quantity).to eq(5) + end + + it 'returns zero when there are no donations' do + expect(product_drive.donation_quantity).to eq(0) + end + + it 'returns zero for donations without line items' do + donation_without_items.save + expect(product_drive.donation_quantity).to eq(0) + end + + it 'sums quantities for multiple donations with line items' do + donation_with_items.save + another_donation_with_items = build(:donation, :with_items, product_drive: product_drive, organization: organization, item_quantity: 10) + another_donation_with_items.save + expect(product_drive.donation_quantity).to eq(15) + end + + it 'handles negative quantities' do + negative_quantity_donation.save + expect(product_drive.donation_quantity).to eq(-5) + end + + it 'handles very large quantities' do + large_quantity_donation.save + expect(product_drive.donation_quantity).to eq(1_000_000) + end + + it 'performs efficiently with a large dataset' do + 1000.times { build(:donation, :with_items, product_drive: product_drive, organization: organization, item_quantity: 1).save } + expect(product_drive.donation_quantity).to eq(1000) + end +end +describe '#distinct_items_count', :phoenix do + let(:organization) { create(:organization) } + let(:product_drive) { create(:product_drive, organization: organization) } + let(:donation_with_items) { create(:donation, :with_items, product_drive: product_drive, organization: organization) } + let(:donation_without_items) { create(:donation, product_drive: product_drive, organization: organization) } + + it 'returns 0 when there are no donations' do + expect(product_drive.distinct_items_count).to eq(0) + end + + it 'returns 0 when donations have no line items' do + donation_without_items + expect(product_drive.distinct_items_count).to eq(0) + end + + it 'returns the correct count when all item_ids are distinct' do + donation_with_items + expect(product_drive.distinct_items_count).to eq(donation_with_items.line_items.count) + end + + describe 'when there are duplicate item_ids' do + before do + create(:line_item, item_id: donation_with_items.line_items.first.item_id, donation: donation_with_items) + end + + it 'returns the correct distinct count' do + expect(product_drive.distinct_items_count).to eq(donation_with_items.line_items.distinct.count) + end + end +end +describe '#in_kind_value', :phoenix do + let(:organization) { create(:organization) } + let(:product_drive) { build(:product_drive, organization: organization) } + + before do + allow(product_drive).to receive(:donations).and_return(donations) + end + + context 'when donations collection is empty' do + let(:donations) { [] } + + it 'returns 0' do + expect(product_drive.in_kind_value).to eq(0) + end + end + + context 'when donations have no line items' do + let(:donations) { build_list(:donation, 3, product_drive: product_drive, organization: organization) } + + it 'returns 0' do + expect(product_drive.in_kind_value).to eq(0) + end + end + + context 'when donations have positive line item values' do + let(:donations) do + build_list(:donation, 3, :with_items, item_quantity: 5, product_drive: product_drive, organization: organization) + end + + it 'calculates the sum of positive values from line items' do + total_value = donations.sum { |donation| donation.value_per_itemizable } + expect(product_drive.in_kind_value).to eq(total_value) + end + end + + context 'when line items have zero values' do + let(:donations) do + build_list(:donation, 3, :with_items, item_quantity: 0, product_drive: product_drive, organization: organization) + end + + it 'returns 0 when line items have zero values' do + expect(product_drive.in_kind_value).to eq(0) + end + end + + context 'when line items have negative values' do + let(:donations) do + build_list(:donation, 3, :with_items, item_quantity: -5, product_drive: product_drive, organization: organization) + end + + it 'calculates the sum when line items have negative values' do + total_value = donations.sum { |donation| donation.value_per_itemizable } + expect(product_drive.in_kind_value).to eq(total_value) + end + end +end +describe '#donation_source_view', :phoenix do + let(:product_drive_with_name) { build(:product_drive, name: 'Test Drive') } + let(:product_drive_empty_name) { build(:product_drive, name: '') } + let(:product_drive_special_chars) { build(:product_drive, name: '@Special!') } + let(:product_drive_nil_name) { build(:product_drive, name: nil) } + + it 'returns the name with (product drive) for a regular name' do + expect(product_drive_with_name.donation_source_view).to eq('Test Drive (product drive)') + end + + it 'returns (product drive) when name is empty' do + expect(product_drive_empty_name.donation_source_view).to eq('(product drive)') + end + + it 'handles special characters in the name' do + expect(product_drive_special_chars.donation_source_view).to eq('@Special! (product drive)') + end + + it 'handles nil name gracefully' do + expect(product_drive_nil_name.donation_source_view).to eq('(product drive)') + end +end +describe '.search_date_range', :phoenix do + let(:valid_date_range) { '2023-01-01 - 2023-12-31' } + let(:invalid_date_format) { '01/01/2023 - 12/31/2023' } + let(:invalid_date_values) { '2023-02-30 - 2023-02-31' } + let(:empty_string) { '' } + let(:whitespace_string) { ' ' } + let(:single_date) { '2023-01-01' } + let(:reversed_date_range) { '2023-12-31 - 2023-01-01' } + let(:non_date_strings) { 'start - end' } + let(:leap_year_dates) { '2024-02-29 - 2024-03-01' } + let(:boundary_date_values) { '2023-01-01 - 2023-12-31' } + + it 'parses a valid date range' do + result = ProductDrive.search_date_range(valid_date_range) + expect(result).to eq({ start_date: '2023-01-01', end_date: '2023-12-31' }) + end + + it 'returns input for invalid date format' do + result = ProductDrive.search_date_range(invalid_date_format) + expect(result).to eq({ start_date: '01/01/2023', end_date: '12/31/2023' }) + end + + it 'returns input for invalid date values' do + result = ProductDrive.search_date_range(invalid_date_values) + expect(result).to eq({ start_date: '2023-02-30', end_date: '2023-02-31' }) + end + + it 'returns nil for empty string' do + result = ProductDrive.search_date_range(empty_string) + expect(result).to eq({ start_date: nil, end_date: nil }) + end + + it 'returns nil for whitespace string' do + result = ProductDrive.search_date_range(whitespace_string) + expect(result).to eq({ start_date: nil, end_date: nil }) + end + + it 'returns single date with nil end date' do + result = ProductDrive.search_date_range(single_date) + expect(result).to eq({ start_date: '2023-01-01', end_date: nil }) + end + + it 'returns reversed date range as is' do + result = ProductDrive.search_date_range(reversed_date_range) + expect(result).to eq({ start_date: '2023-12-31', end_date: '2023-01-01' }) + end + + it 'returns input for non-date strings' do + result = ProductDrive.search_date_range(non_date_strings) + expect(result).to eq({ start_date: 'start', end_date: 'end' }) + end + + it 'parses leap year dates correctly' do + result = ProductDrive.search_date_range(leap_year_dates) + expect(result).to eq({ start_date: '2024-02-29', end_date: '2024-03-01' }) + end + + it 'parses boundary date values correctly' do + result = ProductDrive.search_date_range(boundary_date_values) + expect(result).to eq({ start_date: '2023-01-01', end_date: '2023-12-31' }) + end +end +describe "#item_quantities_by_name_and_date", :phoenix do + let(:organization) { create(:organization) } + let(:item1) { create(:item, organization: organization) } + let(:item2) { create(:item, organization: organization) } + let(:donation1) { create(:donation, :with_items, organization: organization, item: item1, item_quantity: 10, created_at: Time.zone.today - 3.days) } + let(:donation2) { create(:donation, :with_items, organization: organization, item: item2, item_quantity: 5, created_at: Time.zone.today - 2.days) } + let(:date_range) { (Time.zone.today - 1.week)..Time.zone.today } + + it "returns correct quantities for items within the date range" do + quantities = organization.item_quantities_by_name_and_date(date_range) + expect(quantities).to eq([10, 5]) + end + + it "returns zero quantities for an empty date range" do + empty_range = (Time.zone.today + 1.day)..(Time.zone.today + 2.days) + quantities = organization.item_quantities_by_name_and_date(empty_range) + expect(quantities).to eq([0, 0]) + end + + it "returns zero quantities when there are no donations" do + allow(organization).to receive(:donations).and_return(Donation.none) + quantities = organization.item_quantities_by_name_and_date(date_range) + expect(quantities).to eq([0, 0]) + end + + it "returns zero quantities when there are no line items" do + allow(donation1).to receive(:line_items).and_return(LineItem.none) + allow(donation2).to receive(:line_items).and_return(LineItem.none) + quantities = organization.item_quantities_by_name_and_date(date_range) + expect(quantities).to eq([0, 0]) + end + + it "sums quantities for multiple items correctly" do + donation3 = create(:donation, :with_items, organization: organization, item: item1, item_quantity: 15, created_at: Time.zone.today - 1.day) + quantities = organization.item_quantities_by_name_and_date(date_range) + expect(quantities).to eq([25, 5]) + end + + it "returns an empty array when there are no items" do + allow(organization).to receive(:items).and_return(Item.none) + quantities = organization.item_quantities_by_name_and_date(date_range) + expect(quantities).to eq([]) + end + + it "handles boundary dates correctly" do + donation4 = create(:donation, :with_items, organization: organization, item: item1, item_quantity: 20, created_at: date_range.first) + donation5 = create(:donation, :with_items, organization: organization, item: item2, item_quantity: 30, created_at: date_range.last) + quantities = organization.item_quantities_by_name_and_date(date_range) + expect(quantities).to eq([30, 35]) + end +end +describe '#donation_quantity_by_date', :phoenix do + let(:organization) { create(:organization) } + let(:item_category) { create(:item_category, organization: organization) } + let(:item) { create(:item, organization: organization, item_category: item_category) } + let(:donation) { create(:donation, :with_items, organization: organization, item: item) } + let(:line_item) { create(:line_item, item: item, itemizable: donation, quantity: 5) } + let(:date_range) { (Time.zone.today.beginning_of_month..Time.zone.today.end_of_month) } + + subject { ProductDrive.new.donation_quantity_by_date(date_range, item_category_id) } + + context 'when item_category_id is provided' do + let(:item_category_id) { item_category.id } + + it 'sums the quantity of line_items for the given item_category_id' do + expect(subject).to eq(5) + end + end + + context 'when item_category_id is not provided' do + let(:item_category_id) { nil } + + it 'sums the quantity of all line_items' do + expect(subject).to eq(5) + end + end + + context 'when date_range is provided' do + let(:item_category_id) { nil } + + it 'filters donations by the given date_range' do + expect(subject).to eq(5) + end + end + + context 'edge cases' do + let(:item_category_id) { nil } + + it 'handles empty date_range' do + empty_date_range = (Time.zone.today..Time.zone.today) + expect(ProductDrive.new.donation_quantity_by_date(empty_date_range, item_category_id)).to eq(0) + end + + it 'handles invalid date_range' do + invalid_date_range = (Time.zone.today..Time.zone.today - 1.day) + expect(ProductDrive.new.donation_quantity_by_date(invalid_date_range, item_category_id)).to eq(0) + end + + it 'handles no donations in the given date_range' do + future_date_range = (Time.zone.today.next_month.beginning_of_month..Time.zone.today.next_month.end_of_month) + expect(ProductDrive.new.donation_quantity_by_date(future_date_range, item_category_id)).to eq(0) + end + + it 'handles no line items for the given item_category_id' do + different_item_category = create(:item_category, organization: organization) + expect(ProductDrive.new.donation_quantity_by_date(date_range, different_item_category.id)).to eq(0) + end + end +end +describe "#distinct_items_count_by_date", :phoenix do + let(:organization) { create(:organization) } + let(:item_category) { create(:item_category, organization: organization) } + let(:item) { create(:item, organization: organization, item_category: item_category) } + let(:donation) { create(:donation, :with_items, organization: organization, item: item) } + + context "without item category" do + let(:date_range) { Date.today.beginning_of_month..Date.today.end_of_month } + let!(:donation1) { create(:donation, :with_items, organization: organization, issued_at: Date.today, item: item) } + let!(:donation2) { create(:donation, :with_items, organization: organization, issued_at: Date.today, item: item) } + + it "returns distinct item count for a given date range" do + expect(donation.distinct_items_count_by_date(date_range)).to eq(1) + end + end + + context "with item category" do + let(:date_range) { Date.today.beginning_of_month..Date.today.end_of_month } + let!(:donation1) { create(:donation, :with_items, organization: organization, issued_at: Date.today, item: item) } + + it "returns distinct item count for a given date range with item category" do + expect(donation.distinct_items_count_by_date(date_range, item_category.id)).to eq(1) + end + end + + describe "when item_category_id is present" do + let(:date_range) { Date.today.beginning_of_month..Date.today.end_of_month } + + context "and items match the category" do + let!(:donation1) { create(:donation, :with_items, organization: organization, issued_at: Date.today, item: item) } + + it "returns distinct item count" do + expect(donation.distinct_items_count_by_date(date_range, item_category.id)).to eq(1) + end + end + + context "and no items match the category" do + let(:other_item) { create(:item, organization: organization) } + let!(:donation1) { create(:donation, :with_items, organization: organization, issued_at: Date.today, item: other_item) } + + it "returns zero" do + expect(donation.distinct_items_count_by_date(date_range, item_category.id)).to eq(0) + end + end + end + + context "with empty or invalid date range" do + let(:date_range) { nil } + + it "handles empty or invalid date range" do + expect(donation.distinct_items_count_by_date(date_range)).to eq(0) + end + end + + context "when there are no donations in the date range" do + let(:date_range) { Date.today.beginning_of_month..Date.today.end_of_month } + let!(:donation1) { create(:donation, :with_items, organization: organization, issued_at: Date.today.prev_month, item: item) } + + it "returns zero" do + expect(donation.distinct_items_count_by_date(date_range)).to eq(0) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/purchase_spec.rb b/phoenix-tests/unit/tests/app/models/purchase_spec.rb new file mode 100644 index 0000000000..35d05683b3 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/purchase_spec.rb @@ -0,0 +1,404 @@ + +require "rails_helper" + +RSpec.describe Purchase do +describe '#storage_view', :phoenix do + let(:storage_location) { build(:storage_location, name: 'Main Warehouse') } + let(:purchase_with_location) { build(:purchase, storage_location: storage_location) } + let(:purchase_without_location) { build(:purchase, storage_location: nil) } + + it 'returns "N/A" when storage_location is nil' do + expect(purchase_without_location.storage_view).to eq('N/A') + end + + it 'returns the name of the storage_location when it is not nil' do + expect(purchase_with_location.storage_view).to eq('Main Warehouse') + end +end +describe "#purchased_from_view", :phoenix do + let(:vendor) { build(:vendor, business_name: "Awesome Business") } + let(:purchase_with_vendor) { build(:purchase, vendor: vendor) } + let(:purchase_without_vendor) { build(:purchase, vendor: nil, purchased_from: "Google") } + + it "returns purchased_from when vendor is nil" do + purchase = purchase_without_vendor + expect(purchase.purchased_from_view).to eq("Google") + end + + it "returns vendor's business_name when vendor is not nil" do + purchase = purchase_with_vendor + expect(purchase.purchased_from_view).to eq("Awesome Business") + end +end +describe '#amount_spent_in_dollars', :phoenix do + let(:purchase) { build(:purchase, amount_spent_in_cents: amount_spent_in_cents) } + + it 'converts amount spent to dollars as a float' do + let(:amount_spent_in_cents) { 1234 } + expect(purchase.amount_spent_in_dollars).to eq(12.34) + end + + describe 'when amount_spent is zero' do + let(:amount_spent_in_cents) { 0 } + + it 'returns 0.0' do + expect(purchase.amount_spent_in_dollars).to eq(0.0) + end + end + + describe 'when amount_spent is negative' do + let(:amount_spent_in_cents) { -100 } + + it 'returns negative dollar amount' do + expect(purchase.amount_spent_in_dollars).to eq(-1.0) + end + end + + # Assuming currency conversion logic is handled elsewhere, this test might be redundant + # If conversion logic is part of this method, ensure to test it separately +end +describe '#remove', :phoenix do + let(:purchase) { create(:purchase, :with_items, item_quantity: 1) } + let(:line_item) { purchase.line_items.first } + let(:non_existent_id) { -1 } + + it 'removes the line item when the item is an ID and exists' do + purchase.remove(line_item.item_id) + expect(purchase.line_items).to be_empty + end + + it 'does nothing when the item is an ID and does not exist' do + purchase.remove(non_existent_id) + expect(purchase.line_items.count).to eq(1) + end + + it 'removes the line item when the item is an object and exists' do + purchase.remove(line_item) + expect(purchase.line_items).to be_empty + end + + it 'does nothing when the item is an object and does not exist' do + non_existent_item = build(:item, id: non_existent_id) + purchase.remove(non_existent_item) + expect(purchase.line_items.count).to eq(1) + end + + describe 'when item is nil or invalid' do + it 'does not raise an error for nil item' do + expect { purchase.remove(nil) }.not_to raise_error + end + + it 'does not raise an error for invalid item' do + expect { purchase.remove('invalid') }.not_to raise_error + end + end +end +describe '.organization_summary_by_dates', :phoenix do + let(:organization) { create(:organization) } + let(:vendor) { create(:vendor, organization: organization) } + let(:date_range) { Date.today.beginning_of_month..Date.today.end_of_month } + let!(:purchase) { create(:purchase, organization: organization, vendor: vendor, amount_spent_in_cents: 1000, amount_spent_on_period_supplies_cents: 300, amount_spent_on_diapers_cents: 200, amount_spent_on_adult_incontinence_cents: 100, amount_spent_on_other_cents: 400, created_at: Date.today) } + let!(:purchase_with_items) { create(:purchase, :with_items, organization: organization, vendor: vendor, created_at: Date.today) } + + it 'returns zero amounts when there are no purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, Date.today.next_month..Date.today.next_month.end_of_month) + expect(summary.amount_spent).to eq(0) + end + + it 'returns zero period supplies when there are no purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, Date.today.next_month..Date.today.next_month.end_of_month) + expect(summary.period_supplies).to eq(0) + end + + it 'returns zero diapers when there are no purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, Date.today.next_month..Date.today.next_month.end_of_month) + expect(summary.diapers).to eq(0) + end + + it 'returns zero adult incontinence when there are no purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, Date.today.next_month..Date.today.next_month.end_of_month) + expect(summary.adult_incontinence).to eq(0) + end + + it 'returns zero other categories when there are no purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, Date.today.next_month..Date.today.next_month.end_of_month) + expect(summary.other).to eq(0) + end + + it 'returns zero total items when there are no purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, Date.today.next_month..Date.today.next_month.end_of_month) + expect(summary.total_items).to eq(0) + end + + it 'calculates total amount spent when there are purchases in the date range' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.amount_spent).to eq(1000) + end + + describe 'when purchases include different categories' do + it 'calculates amount spent on period supplies' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.period_supplies).to eq(300) + end + + it 'calculates amount spent on diapers' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.diapers).to eq(200) + end + + it 'calculates amount spent on adult incontinence' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.adult_incontinence).to eq(100) + end + + it 'calculates amount spent on other categories' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.other).to eq(400) + end + end + + it 'calculates total items from line items quantities' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.total_items).to eq(purchase_with_items.line_items.sum(:quantity)) + end + + it 'includes recent purchases with vendors' do + summary = Purchase.organization_summary_by_dates(organization, date_range) + expect(summary.recent_purchases).to include(purchase) + end +end +describe '#combine_duplicates', :phoenix do + let(:purchase) { build(:purchase) } + + context 'when there are no line items' do + it 'does not change the line items size' do + expect { purchase.combine_duplicates }.not_to change { purchase.line_items.size } + end + end + + context 'when there are valid line items with non-zero quantities' do + let(:purchase) { build(:purchase, :with_items, item_quantity: 5) } + + it 'calls combine! on line items' do + expect(purchase.line_items).to receive(:combine!) + purchase.combine_duplicates + end + end + + context 'when there are line items with zero quantities' do + let(:purchase) { build(:purchase, :with_items, item_quantity: 0) } + + it 'does not change the line items size' do + expect { purchase.combine_duplicates }.not_to change { purchase.line_items.size } + end + end + + context 'when there are invalid line items' do + let(:purchase) { build(:purchase) } + + before do + allow_any_instance_of(LineItem).to receive(:valid?).and_return(false) + end + + it 'does not change the line items size' do + expect { purchase.combine_duplicates }.not_to change { purchase.line_items.size } + end + end + + context 'when there are line items with the same item_id' do + let(:item) { create(:item) } + let(:purchase) { build(:purchase, :with_items, item: item, item_quantity: 5) } + + it 'increases the total quantity of line items by 5' do + expect { purchase.combine_duplicates }.to change { purchase.line_items.map(&:quantity).sum }.by(5) + end + end +end +describe '#strip_symbols_from_money', :phoenix do + let(:purchase_with_symbols) { build(:purchase, amount_spent: '$1,000', amount_spent_on_diapers: '$200', amount_spent_on_adult_incontinence: '$300', amount_spent_on_period_supplies: '$100', amount_spent_on_other: '$400') } + let(:purchase_without_symbols) { build(:purchase, amount_spent: '1000', amount_spent_on_diapers: '200', amount_spent_on_adult_incontinence: '300', amount_spent_on_period_supplies: '100', amount_spent_on_other: '400') } + let(:purchase_non_string) { build(:purchase, amount_spent: 1000, amount_spent_on_diapers: 200, amount_spent_on_adult_incontinence: 300, amount_spent_on_period_supplies: 100, amount_spent_on_other: 400) } + let(:purchase_only_symbols) { build(:purchase, amount_spent: '$', amount_spent_on_diapers: '$', amount_spent_on_adult_incontinence: '$', amount_spent_on_period_supplies: '$', amount_spent_on_other: '$') } + let(:purchase_empty_string) { build(:purchase, amount_spent: '', amount_spent_on_diapers: '', amount_spent_on_adult_incontinence: '', amount_spent_on_period_supplies: '', amount_spent_on_other: '') } + + it 'strips symbols from amount_spent' do + purchase_with_symbols.strip_symbols_from_money + expect(purchase_with_symbols.amount_spent).to eq(1000) + end + + it 'strips symbols from amount_spent_on_diapers' do + purchase_with_symbols.strip_symbols_from_money + expect(purchase_with_symbols.amount_spent_on_diapers).to eq(200) + end + + it 'strips symbols from amount_spent_on_adult_incontinence' do + purchase_with_symbols.strip_symbols_from_money + expect(purchase_with_symbols.amount_spent_on_adult_incontinence).to eq(300) + end + + it 'strips symbols from amount_spent_on_period_supplies' do + purchase_with_symbols.strip_symbols_from_money + expect(purchase_with_symbols.amount_spent_on_period_supplies).to eq(100) + end + + it 'strips symbols from amount_spent_on_other' do + purchase_with_symbols.strip_symbols_from_money + expect(purchase_with_symbols.amount_spent_on_other).to eq(400) + end + + it 'handles string without symbols for amount_spent' do + purchase_without_symbols.strip_symbols_from_money + expect(purchase_without_symbols.amount_spent).to eq(1000) + end + + it 'handles string without symbols for amount_spent_on_diapers' do + purchase_without_symbols.strip_symbols_from_money + expect(purchase_without_symbols.amount_spent_on_diapers).to eq(200) + end + + it 'handles string without symbols for amount_spent_on_adult_incontinence' do + purchase_without_symbols.strip_symbols_from_money + expect(purchase_without_symbols.amount_spent_on_adult_incontinence).to eq(300) + end + + it 'handles string without symbols for amount_spent_on_period_supplies' do + purchase_without_symbols.strip_symbols_from_money + expect(purchase_without_symbols.amount_spent_on_period_supplies).to eq(100) + end + + it 'handles string without symbols for amount_spent_on_other' do + purchase_without_symbols.strip_symbols_from_money + expect(purchase_without_symbols.amount_spent_on_other).to eq(400) + end + + it 'does nothing if amount_spent is not a string' do + purchase_non_string.strip_symbols_from_money + expect(purchase_non_string.amount_spent).to eq(1000) + end + + it 'does nothing if amount_spent_on_diapers is not a string' do + purchase_non_string.strip_symbols_from_money + expect(purchase_non_string.amount_spent_on_diapers).to eq(200) + end + + it 'does nothing if amount_spent_on_adult_incontinence is not a string' do + purchase_non_string.strip_symbols_from_money + expect(purchase_non_string.amount_spent_on_adult_incontinence).to eq(300) + end + + it 'does nothing if amount_spent_on_period_supplies is not a string' do + purchase_non_string.strip_symbols_from_money + expect(purchase_non_string.amount_spent_on_period_supplies).to eq(100) + end + + it 'does nothing if amount_spent_on_other is not a string' do + purchase_non_string.strip_symbols_from_money + expect(purchase_non_string.amount_spent_on_other).to eq(400) + end + + it 'handles string with only symbols for amount_spent' do + purchase_only_symbols.strip_symbols_from_money + expect(purchase_only_symbols.amount_spent).to eq(0) + end + + it 'handles string with only symbols for amount_spent_on_diapers' do + purchase_only_symbols.strip_symbols_from_money + expect(purchase_only_symbols.amount_spent_on_diapers).to eq(0) + end + + it 'handles string with only symbols for amount_spent_on_adult_incontinence' do + purchase_only_symbols.strip_symbols_from_money + expect(purchase_only_symbols.amount_spent_on_adult_incontinence).to eq(0) + end + + it 'handles string with only symbols for amount_spent_on_period_supplies' do + purchase_only_symbols.strip_symbols_from_money + expect(purchase_only_symbols.amount_spent_on_period_supplies).to eq(0) + end + + it 'handles string with only symbols for amount_spent_on_other' do + purchase_only_symbols.strip_symbols_from_money + expect(purchase_only_symbols.amount_spent_on_other).to eq(0) + end + + it 'handles empty string for amount_spent' do + purchase_empty_string.strip_symbols_from_money + expect(purchase_empty_string.amount_spent).to eq(0) + end + + it 'handles empty string for amount_spent_on_diapers' do + purchase_empty_string.strip_symbols_from_money + expect(purchase_empty_string.amount_spent_on_diapers).to eq(0) + end + + it 'handles empty string for amount_spent_on_adult_incontinence' do + purchase_empty_string.strip_symbols_from_money + expect(purchase_empty_string.amount_spent_on_adult_incontinence).to eq(0) + end + + it 'handles empty string for amount_spent_on_period_supplies' do + purchase_empty_string.strip_symbols_from_money + expect(purchase_empty_string.amount_spent_on_period_supplies).to eq(0) + end + + it 'handles empty string for amount_spent_on_other' do + purchase_empty_string.strip_symbols_from_money + expect(purchase_empty_string.amount_spent_on_other).to eq(0) + end +end +describe '#total_equal_to_all_categories', :phoenix do + let(:purchase) { build(:purchase, amount_spent_in_cents: amount_spent_in_cents, amount_spent_on_diapers_cents: amount_spent_on_diapers_cents, amount_spent_on_adult_incontinence_cents: amount_spent_on_adult_incontinence_cents, amount_spent_on_period_supplies_cents: amount_spent_on_period_supplies_cents, amount_spent_on_other_cents: amount_spent_on_other_cents) } + + context 'when amount_spent is nil or zero' do + let(:amount_spent_in_cents) { 0 } + let(:amount_spent_on_diapers_cents) { 0 } + let(:amount_spent_on_adult_incontinence_cents) { 0 } + let(:amount_spent_on_period_supplies_cents) { 0 } + let(:amount_spent_on_other_cents) { 0 } + + it 'returns without adding an error' do + purchase.total_equal_to_all_categories + expect(purchase.errors[:amount_spent]).to be_empty + end + end + + context 'when all category amounts are nil or zero' do + let(:amount_spent_in_cents) { 1000 } + let(:amount_spent_on_diapers_cents) { 0 } + let(:amount_spent_on_adult_incontinence_cents) { 0 } + let(:amount_spent_on_period_supplies_cents) { 0 } + let(:amount_spent_on_other_cents) { 0 } + + it 'returns without adding an error' do + purchase.total_equal_to_all_categories + expect(purchase.errors[:amount_spent]).to be_empty + end + end + + context 'when category total equals amount_spent' do + let(:amount_spent_in_cents) { 1000 } + let(:amount_spent_on_diapers_cents) { 250 } + let(:amount_spent_on_adult_incontinence_cents) { 250 } + let(:amount_spent_on_period_supplies_cents) { 250 } + let(:amount_spent_on_other_cents) { 250 } + + it 'does not add an error' do + purchase.total_equal_to_all_categories + expect(purchase.errors[:amount_spent]).to be_empty + end + end + + context 'when category total does not equal amount_spent' do + let(:amount_spent_in_cents) { 1000 } + let(:amount_spent_on_diapers_cents) { 200 } + let(:amount_spent_on_adult_incontinence_cents) { 200 } + let(:amount_spent_on_period_supplies_cents) { 200 } + let(:amount_spent_on_other_cents) { 200 } + + it 'adds an error' do + purchase.total_equal_to_all_categories + expect(purchase.errors[:amount_spent]).to include('does not equal all categories - categories add to $8.00 but given total is $10.00') + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/question_spec.rb b/phoenix-tests/unit/tests/app/models/question_spec.rb new file mode 100644 index 0000000000..0edeec0540 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/question_spec.rb @@ -0,0 +1,48 @@ + +require "rails_helper" + +RSpec.describe Question do +describe '#punctuate', :phoenix do + let(:question) { build(:question) } + + it 'returns an empty string when errors array is empty' do + errors = [] + expect(question.punctuate(errors)).to eq('') + end + + it 'appends a period to each error when no redundant error is present' do + errors = ['error1', 'error2'] + expect(question.punctuate(errors)).to eq('error1. error2. ') + end + + it 'removes the specific redundant error from the errors array' do + errors = ['For banks and for partners can\'t both be unchecked'] + expect(question.punctuate(errors)).to eq('') + end + + it 'removes multiple instances of the specific redundant error' do + errors = ['For banks and for partners can\'t both be unchecked', 'For banks and for partners can\'t both be unchecked'] + expect(question.punctuate(errors)).to eq('') + end + + it 'retains non-redundant errors and removes redundant ones' do + errors = ['For banks and for partners can\'t both be unchecked', 'error1', 'For banks and for partners can\'t both be unchecked', 'error2'] + expect(question.punctuate(errors)).to eq('error1. error2. ') + end +end +describe '#remove_redundant_error', :phoenix do + let(:question) { build(:question) } + let(:errors_with_redundant) { ["For banks and for partners can't both be unchecked", "Another error"] } + let(:errors_without_redundant) { ["Another error"] } + + it 'removes the redundant error when present' do + result = question.remove_redundant_error(errors_with_redundant) + expect(result).not_to include("For banks and for partners can't both be unchecked") + end + + it 'returns the same errors when the redundant error is not present' do + result = question.remove_redundant_error(errors_without_redundant) + expect(result).to eq(errors_without_redundant) + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/request_item_spec.rb b/phoenix-tests/unit/tests/app/models/request_item_spec.rb new file mode 100644 index 0000000000..e78b6894c5 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/request_item_spec.rb @@ -0,0 +1,147 @@ + +require "rails_helper" + +RSpec.describe RequestItem do +describe "#from_json", :phoenix do + let(:organization) { create(:organization, :with_items) } + let(:partner) { create(:partner, organization: organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:item) { create(:item, organization: organization) } + let(:request) { create(:request, :with_item_requests, partner: partner, organization: organization) } + let(:json) { { 'item_id' => item.id, 'quantity' => 5 } } + let(:inventory) { Inventory.create(organization: organization) } + + it "creates a RequestItem from valid JSON input" do + request_item = RequestItem.from_json(json, request) + expect(request_item.item).to eq(item) + end + + it "assigns the correct quantity from JSON input" do + request_item = RequestItem.from_json(json, request) + expect(request_item.quantity).to eq(5) + end + + describe "when default storage location is present" do + it "uses partner's default storage location" do + allow(partner).to receive(:default_storage_location_id).and_return(storage_location.id) + request_item = RequestItem.from_json(json, request) + expect(request_item.storage_location).to eq(storage_location) + end + + it "falls back to organization's default storage location" do + allow(partner).to receive(:default_storage_location_id).and_return(nil) + allow(organization).to receive(:default_storage_location).and_return(storage_location.id) + request_item = RequestItem.from_json(json, request) + expect(request_item.storage_location).to eq(storage_location) + end + end + + it "retrieves unit from item requests" do + unit = request.item_requests.find { |item_request| item_request.item_id == item.id }&.request_unit + request_item = RequestItem.from_json(json, request) + expect(request_item.unit).to eq(unit) + end + + describe "when inventory is provided" do + it "calculates on hand quantity" do + allow(inventory).to receive(:quantity_for).with(item_id: item.id).and_return(10) + request_item = RequestItem.from_json(json, request, inventory) + expect(request_item.on_hand).to eq(10) + end + + it "calculates on hand for location" do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(5) + request_item = RequestItem.from_json(json, request, inventory) + expect(request_item.on_hand_for_location).to eq(5) + end + end + + describe "when location is found" do + it "handles found location" do + allow(StorageLocation).to receive(:find_by).with(id: storage_location.id).and_return(storage_location) + request_item = RequestItem.from_json(json, request) + expect(request_item.storage_location).to eq(storage_location) + end + + it "handles location not found" do + allow(StorageLocation).to receive(:find_by).with(id: storage_location.id).and_return(nil) + request_item = RequestItem.from_json(json, request) + expect(request_item.storage_location).to be_nil + end + end + + describe "when on hand for location is calculated" do + it "handles positive on hand for location" do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(5) + request_item = RequestItem.from_json(json, request, inventory) + expect(request_item.on_hand_for_location).to eq(5) + end + + it "handles non-positive on hand for location" do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(0) + request_item = RequestItem.from_json(json, request, inventory) + expect(request_item.on_hand_for_location).to eq('N/A') + end + end +end +describe '#initialize', :phoenix do + let(:item) { Item.create(name: 'Test Item') } + let(:quantity) { 10 } + let(:unit) { 'kg' } + let(:on_hand) { 50 } + let(:on_hand_for_location) { 30 } + + it 'initializes with valid parameters' do + request_item = RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) + expect(request_item).to be_a(RequestItem) + end + + describe 'when item is nil' do + let(:item) { nil } + + it 'raises an error for nil item' do + expect { RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) }.to raise_error(ArgumentError) + end + end + + describe 'when quantity is nil' do + let(:quantity) { nil } + + it 'raises an error for nil quantity' do + expect { RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) }.to raise_error(ArgumentError) + end + end + + describe 'when unit is nil' do + let(:unit) { nil } + + it 'raises an error for nil unit' do + expect { RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) }.to raise_error(ArgumentError) + end + end + + describe 'when on_hand is nil' do + let(:on_hand) { nil } + + it 'raises an error for nil on_hand' do + expect { RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) }.to raise_error(ArgumentError) + end + end + + describe 'when on_hand_for_location is nil' do + let(:on_hand_for_location) { nil } + + it 'raises an error for nil on_hand_for_location' do + expect { RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) }.to raise_error(ArgumentError) + end + end + + describe 'when parameters are of unexpected types' do + let(:quantity) { 'ten' } + + it 'raises an error for unexpected type of quantity' do + expect { RequestItem.new(item, quantity, unit, on_hand, on_hand_for_location) }.to raise_error(TypeError) + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/request_spec.rb b/phoenix-tests/unit/tests/app/models/request_spec.rb new file mode 100644 index 0000000000..d6b1465e1c --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/request_spec.rb @@ -0,0 +1,227 @@ + +require "rails_helper" + +RSpec.describe Request do +describe "#total_items", :phoenix do + let(:request) { build(:request, request_items: request_items) } + + context "when request_items is empty" do + let(:request_items) { [] } + + it "returns 0" do + expect(request.total_items).to eq(0) + end + end + + context "when request_items have positive quantities" do + let(:request_items) { [{ "item_id" => 1, "quantity" => 5 }, { "item_id" => 2, "quantity" => 10 }] } + + it "sums up positive quantities correctly" do + expect(request.total_items).to eq(15) + end + end + + context "when all quantities are zero" do + let(:request_items) { [{ "item_id" => 1, "quantity" => 0 }, { "item_id" => 2, "quantity" => 0 }] } + + it "returns 0" do + expect(request.total_items).to eq(0) + end + end + + context "when request_items have negative quantities" do + let(:request_items) { [{ "item_id" => 1, "quantity" => -5 }, { "item_id" => 2, "quantity" => -10 }] } + + it "handles negative quantities correctly" do + expect(request.total_items).to eq(-15) + end + end + + context "when request_items have a mix of positive, zero, and negative quantities" do + let(:request_items) { [{ "item_id" => 1, "quantity" => 5 }, { "item_id" => 2, "quantity" => 0 }, { "item_id" => 3, "quantity" => -3 }] } + + it "sums up a mix of positive, zero, and negative quantities correctly" do + expect(request.total_items).to eq(2) + end + end +end +describe '#user_email', :phoenix do + let(:user) { build(:user) } + let(:partner) { build(:partner) } + + context 'when partner_user_id is present' do + let(:request_with_user) { build(:request, partner_user_id: user.id) } + + it 'returns the email of the user with partner_user_id' do + allow(User).to receive(:find_by).with(id: user.id).and_return(user) + expect(request_with_user.user_email).to eq(user.email) + end + + it 'returns nil if the user with partner_user_id does not exist' do + allow(User).to receive(:find_by).with(id: user.id).and_return(nil) + expect(request_with_user.user_email).to be_nil + end + end + + context 'when partner_user_id is not present' do + let(:request_with_partner) { build(:request, partner_id: partner.id) } + + it 'returns the email of the partner with partner_id' do + allow(Partner).to receive(:find_by).with(id: partner.id).and_return(partner) + expect(request_with_partner.user_email).to eq(partner.email) + end + + it 'returns nil if the partner with partner_id does not exist' do + allow(Partner).to receive(:find_by).with(id: partner.id).and_return(nil) + expect(request_with_partner.user_email).to be_nil + end + end +end +describe "#request_type_label", :phoenix do + let(:request_nil_type) { build(:request, request_type: nil) } + let(:request_empty_type) { build(:request, request_type: '') } + let(:request_single_char_type) { build(:request, request_type: 'a') } + let(:request_multi_char_type) { build(:request, request_type: 'abc') } + + it "returns nil when request_type is nil" do + expect(request_nil_type.request_type_label).to be_nil + end + + it "returns nil when request_type is an empty string" do + expect(request_empty_type.request_type_label).to be_nil + end + + it "returns the capitalized character when request_type is a single character" do + expect(request_single_char_type.request_type_label).to eq('A') + end + + it "returns the capitalized first character when request_type has multiple characters" do + expect(request_multi_char_type.request_type_label).to eq('A') + end +end +describe '#item_requests_uniqueness_by_item_id', :phoenix do + let(:request) { build(:request, item_requests: item_requests) } + + context 'when all item_ids are unique' do + let(:item_requests) do + [ + Partners::ItemRequest.new(item_id: 1, quantity: 5), + Partners::ItemRequest.new(item_id: 2, quantity: 10) + ] + end + + it 'does not add errors to request' do + request.item_requests_uniqueness_by_item_id + expect(request.errors[:item_requests]).to be_empty + end + end + + context 'when there are duplicate item_ids' do + let(:item_requests) do + [ + Partners::ItemRequest.new(item_id: 1, quantity: 5), + Partners::ItemRequest.new(item_id: 1, quantity: 10) + ] + end + + it 'adds an error for duplicate item_ids' do + request.item_requests_uniqueness_by_item_id + expect(request.errors[:item_requests]).to include('should have unique item_ids') + end + end + + context 'when item_requests is empty' do + let(:item_requests) { [] } + + it 'does not add errors to request' do + request.item_requests_uniqueness_by_item_id + expect(request.errors[:item_requests]).to be_empty + end + end +end +describe '#sanitize_items_data', :phoenix do + let(:request) { build(:request, request_items: request_items) } + + context 'when request_items is nil' do + let(:request_items) { nil } + + it 'does nothing if request_items is nil' do + expect { request.sanitize_items_data }.not_to change { request.request_items } + end + end + + context 'when request_items has not changed' do + let(:request_items) { [{ 'item_id' => 1, 'quantity' => 5 }] } + + before do + allow(request).to receive(:request_items_changed?).and_return(false) + end + + it 'does nothing if request_items has not changed' do + expect { request.sanitize_items_data }.not_to change { request.request_items } + end + end + + describe 'when request_items is present and has changed' do + let(:request_items) { [{ 'item_id' => '1', 'quantity' => '5' }, { 'item_id' => nil, 'quantity' => nil }] } + + before do + allow(request).to receive(:request_items_changed?).and_return(true) + end + + it 'converts item_id to integer' do + request.sanitize_items_data + expect(request.request_items.first['item_id']).to eq(1) + end + + it 'converts quantity to integer' do + request.sanitize_items_data + expect(request.request_items.first['quantity']).to eq(5) + end + + it 'handles items with nil item_id gracefully' do + request.sanitize_items_data + expect(request.request_items.last['item_id']).to be_nil + end + + it 'handles items with nil quantity gracefully' do + request.sanitize_items_data + expect(request.request_items.last['quantity']).to be_nil + end + + context 'when request_items is an empty array' do + let(:request_items) { [] } + + it 'handles an empty request_items array' do + expect { request.sanitize_items_data }.not_to change { request.request_items } + end + end + end +end +describe "#not_completely_empty", :phoenix do + let(:request_with_no_comments_or_item_requests) { build(:request, comments: nil, item_requests: []) } + let(:request_with_comments) { build(:request, comments: "Some comment", item_requests: []) } + let(:request_with_item_requests) { build(:request, comments: nil, item_requests: [ItemRequest.new]) } + let(:request_with_comments_and_item_requests) { build(:request, comments: "Some comment", item_requests: [ItemRequest.new]) } + + it "adds an error when both comments and item_requests are blank" do + request_with_no_comments_or_item_requests.not_completely_empty + expect(request_with_no_comments_or_item_requests.errors[:base]).to include("completely empty request") + end + + it "does not add an error when comments are present and item_requests are blank" do + request_with_comments.not_completely_empty + expect(request_with_comments.errors[:base]).to be_empty + end + + it "does not add an error when comments are blank and item_requests are present" do + request_with_item_requests.not_completely_empty + expect(request_with_item_requests.errors[:base]).to be_empty + end + + it "does not add an error when both comments and item_requests are present" do + request_with_comments_and_item_requests.not_completely_empty + expect(request_with_comments_and_item_requests.errors[:base]).to be_empty + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/role_spec.rb b/phoenix-tests/unit/tests/app/models/role_spec.rb new file mode 100644 index 0000000000..d146c436ff --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/role_spec.rb @@ -0,0 +1,55 @@ + +require "rails_helper" + +RSpec.describe Role do +describe "#title", :phoenix do + let(:valid_role) { build(:role, name: :org_user) } + let(:invalid_role) { build(:role, name: :invalid_key) } + let(:nil_role) { build(:role, name: nil) } + + it "returns the correct title when name is a valid key" do + expect(valid_role.title).to eq("Organization") + end + + it "returns nil when name is not a valid key" do + expect(invalid_role.title).to be_nil + end + + it "returns nil when name is nil" do + expect(nil_role.title).to be_nil + end +end +describe "#resources_for_select", :phoenix do + let(:titles) do + { + org_user: "Organization", + org_admin: "Organization Admin", + partner: "Partner", + super_admin: "Super admin" + } + end + + subject { Role.resources_for_select } + + it "does not include the super admin title" do + expect(subject).not_to have_key("Super admin") + end + + it "inverts the titles hash correctly" do + expected_inverted_hash = { + "Organization" => :org_user, + "Organization Admin" => :org_admin, + "Partner" => :partner + } + expect(subject).to eq(expected_inverted_hash) + end + + it "returns a hash with correct titles as keys" do + expect(subject.keys).to match_array(["Organization", "Organization Admin", "Partner"]) + end + + it "returns a hash with correct roles as values" do + expect(subject.values).to match_array([:org_user, :org_admin, :partner]) + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/storage_location_spec.rb b/phoenix-tests/unit/tests/app/models/storage_location_spec.rb new file mode 100644 index 0000000000..4de1c939d3 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/storage_location_spec.rb @@ -0,0 +1,505 @@ + +require "rails_helper" + +RSpec.describe StorageLocation do +describe "#items_inventoried", :phoenix do + let(:organization) { create(:organization) } + let(:inventory) { View::Inventory.new(organization.id) } + let(:item1) { build(:item, name: "Apple", item_id: 1, organization: organization) } + let(:item2) { build(:item, name: "Banana", item_id: 2, organization: organization) } + let(:item3) { build(:item, name: "Apple", item_id: 1, organization: organization) } + + before do + allow(inventory).to receive(:all_items).and_return([item1, item2, item3]) + end + + it "initializes a new inventory when none is provided" do + result = StorageLocation.items_inventoried(organization) + expect(result).to all(be_an(OpenStruct)) + end + + it "retrieves unique items from the inventory" do + result = StorageLocation.items_inventoried(organization, inventory) + expect(result.size).to eq(2) + end + + it "ensures items are unique by item_id" do + result = StorageLocation.items_inventoried(organization, inventory) + expect(result.map(&:id)).to match_array([1, 2]) + end + + it "sorts items by name" do + result = StorageLocation.items_inventoried(organization, inventory) + expect(result.map(&:name)).to eq(["Apple", "Banana"]) + end + + it "maps items to OpenStruct with name and id" do + result = StorageLocation.items_inventoried(organization, inventory) + expect(result.first.name).to eq("Apple") + expect(result.first.id).to eq(1) + end + + context "when a custom inventory is provided" do + let(:custom_inventory) { instance_double("View::Inventory") } + + before do + allow(custom_inventory).to receive(:all_items).and_return([item1, item2]) + end + + it "uses the provided inventory instead of initializing a new one" do + result = StorageLocation.items_inventoried(organization, custom_inventory) + expect(result.size).to eq(2) + end + + it "retrieves items sorted by name from custom inventory" do + result = StorageLocation.items_inventoried(organization, custom_inventory) + expect(result.map(&:name)).to eq(["Apple", "Banana"]) + end + end +end +describe '#items', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:item1) { create(:item, organization: organization, name: 'Apple') } + let(:item2) { create(:item, organization: organization, name: 'Banana') } + + before do + create(:inventory_item, storage_location: storage_location, item: item1, quantity: 10) + create(:inventory_item, storage_location: storage_location, item: item2, quantity: 0) + end + + it 'returns items with positive quantities' do + expect(storage_location.items).to contain_exactly(item1) + end + + it 'returns no items when there are no positive quantities' do + InventoryItem.update_all(quantity: 0) + expect(storage_location.items).to be_empty + end + + describe 'when include_omitted is true' do + before do + allow(View::Inventory).to receive(:items_for_location).with(storage_location, include_omitted: true).and_return([item1, item2]) + end + + it 'includes active items not present in storage location' do + expect(storage_location.items).to contain_exactly(item1, item2) + end + end + + describe 'when include_omitted is false' do + before do + allow(View::Inventory).to receive(:items_for_location).with(storage_location, include_omitted: false).and_return([item1]) + end + + it 'does not include active items not present in storage location' do + expect(storage_location.items).to contain_exactly(item1) + end + end + + it 'returns an empty array when storage location is empty' do + InventoryItem.where(storage_location: storage_location).destroy_all + expect(storage_location.items).to be_empty + end + + it 'returns items sorted by name' do + create(:inventory_item, storage_location: storage_location, item: item2, quantity: 5) + expect(storage_location.items).to eq([item1, item2]) + end +end +describe '#size', :phoenix do + let(:storage_location) { create(:storage_location) } + + it 'calculates the total quantity of items for a location' do + create(:storage_location, :with_items, item_count: 3, item_quantity: 5, organization: storage_location.organization) + expect(storage_location.size).to eq(15) + end + + context 'when there are no items' do + it 'returns zero' do + expect(storage_location.size).to eq(0) + end + end + + context 'when all items have zero quantity' do + before do + create(:storage_location, :with_items, item_count: 3, item_quantity: 0, organization: storage_location.organization) + end + + it 'returns zero' do + expect(storage_location.size).to eq(0) + end + end + + context 'when some items have zero quantity' do + before do + create(:storage_location, :with_items, item_count: 2, item_quantity: 5, organization: storage_location.organization) + create(:storage_location, :with_items, item_count: 1, item_quantity: 0, organization: storage_location.organization) + end + + it 'calculates the total quantity excluding zero quantities' do + expect(storage_location.size).to eq(10) + end + end + + context 'when there is only one item' do + before do + create(:storage_location, :with_items, item_count: 1, item_quantity: 7, organization: storage_location.organization) + end + + it 'returns the quantity of the single item' do + expect(storage_location.size).to eq(7) + end + end + + context 'when items_for_location returns nil' do + before do + allow(View::Inventory).to receive(:items_for_location).and_return(nil) + end + + it 'handles nil gracefully' do + expect(storage_location.size).to eq(0) + end + end +end +describe '#item_total', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:item) { create(:item, organization: organization) } + let(:inventory) { View::Inventory.new(organization.id) } + + it 'returns the quantity of a specific item at the storage location' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(50) + expect(storage_location.item_total(item.id)).to eq(50) + end + + it 'returns 0 if the item does not exist at the storage location' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(0) + expect(storage_location.item_total(item.id)).to eq(0) + end + + it 'raises NoMethodError if the storage location does not exist' do + expect { StorageLocation.new.item_total(item.id) }.to raise_error(NoMethodError) + end + + it 'returns 0 if inventory is empty' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(nil) + expect(storage_location.item_total(item.id)).to eq(0) + end + + it 'returns 0 if inventory contains nil values' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(nil) + expect(storage_location.item_total(item.id)).to eq(0) + end + + it 'updates quantity after concurrent modifications to the inventory' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(50) + expect(storage_location.item_total(item.id)).to eq(50) + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id, item_id: item.id).and_return(30) + expect(storage_location.item_total(item.id)).to eq(30) + end +end +describe "#inventory_total_value_in_dollars", :phoenix do + let(:storage_location) { create(:storage_location) } + let(:inventory) { instance_double("View::Inventory", total_value_in_dollars: 100) } + + context "when inventory is provided" do + it "calculates total value using provided inventory" do + expect(inventory).to receive(:total_value_in_dollars).with(storage_location: storage_location.id) + expect(storage_location.inventory_total_value_in_dollars(inventory)).to eq(100) + end + end + + context "when inventory is not provided" do + before do + allow(View::Inventory).to receive(:new).and_return(inventory) + end + + it "calculates total value using default inventory" do + expect(inventory).to receive(:total_value_in_dollars).with(storage_location: storage_location.id) + expect(storage_location.inventory_total_value_in_dollars).to eq(100) + end + end + + context "when inventory is nil" do + let(:inventory) { nil } + + it "does not raise error with nil inventory" do + expect { storage_location.inventory_total_value_in_dollars(inventory) }.not_to raise_error + end + end + + it "calls total_value_in_dollars with storage_location id" do + expect(inventory).to receive(:total_value_in_dollars).with(storage_location: storage_location.id) + storage_location.inventory_total_value_in_dollars(inventory) + end + + context "when inventory object is nil" do + let(:inventory) { nil } + + it "does not raise error with nil inventory object" do + expect { storage_location.inventory_total_value_in_dollars }.not_to raise_error + end + end +end +describe '#to_csv', :phoenix do + let(:organization) { create(:organization, :with_items) } + let(:storage_location) { create(:storage_location, organization: organization) } + + it 'generates CSV with correct headers' do + csv_output = storage_location.to_csv + expect(csv_output.lines.first.chomp).to eq('Quantity,DO NOT CHANGE ANYTHING IN THIS COLUMN') + end + + describe 'when organization has items' do + it 'includes all items from the organization' do + csv_output = storage_location.to_csv + organization.items.each do |item| + expect(csv_output).to include(item.name) + end + end + end + + describe 'when there are no items in the organization' do + let(:organization) { create(:organization) } + + it 'generates CSV with only headers' do + csv_output = storage_location.to_csv + expect(csv_output).to eq("Quantity,DO NOT CHANGE ANYTHING IN THIS COLUMN\n") + end + end + + describe 'when the organization is nil' do + let(:storage_location) { build(:storage_location, organization: nil) } + + it 'raises an error or handles gracefully' do + expect { storage_location.to_csv }.to raise_error(NoMethodError) + end + end +end +describe "#import_csv", :phoenix do + let(:organization) { create(:organization) } + let(:valid_csv) { CSV.generate { |csv| csv << ["name", "address", "square_footage", "warehouse_type"]; csv << ["Location 1", "123 Main St", 1000, "Type A"] } } + let(:invalid_csv) { CSV.generate { |csv| csv << ["name", "address", "square_footage", "warehouse_type"]; csv << [nil, "", nil, ""] } } + let(:duplicate_csv) { CSV.generate { |csv| csv << ["name", "address", "square_footage", "warehouse_type"]; csv << ["Location 1", "123 Main St", 1000, "Type A"]; csv << ["Location 1", "123 Main St", 1000, "Type A"] } } + + it "imports all rows successfully" do + expect { StorageLocation.import_csv(CSV.parse(valid_csv, headers: true), organization.id) } + .to change { StorageLocation.count }.by(1) + end + + it "raises error for invalid CSV data" do + expect { StorageLocation.import_csv(CSV.parse(invalid_csv, headers: true), organization.id) } + .to raise_error(ActiveRecord::RecordInvalid) + end + + it "raises error on database save failure" do + allow_any_instance_of(StorageLocation).to receive(:save!).and_raise(ActiveRecord::RecordNotSaved) + expect { StorageLocation.import_csv(CSV.parse(valid_csv, headers: true), organization.id) } + .to raise_error(ActiveRecord::RecordNotSaved) + end + + it "does not change count for empty CSV" do + empty_csv = CSV.generate { |csv| csv << ["name", "address", "square_footage", "warehouse_type"] } + expect { StorageLocation.import_csv(CSV.parse(empty_csv, headers: true), organization.id) } + .not_to change { StorageLocation.count } + end + + it "assigns correct organization ID to imported locations" do + StorageLocation.import_csv(CSV.parse(valid_csv, headers: true), organization.id) + expect(StorageLocation.last.organization_id).to eq(organization.id) + end + + it "imports only unique rows from CSV with duplicates" do + expect { StorageLocation.import_csv(CSV.parse(duplicate_csv, headers: true), organization.id) } + .to change { StorageLocation.count }.by(1) + end +end +describe '#import_inventory', :phoenix do + let(:organization) { create(:organization, :with_items) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:user) { create(:user, :organization_admin, organization: organization) } + let(:item) { organization.items.first } + let(:csv_content) { "quantity,item_name\n10,#{item.name}" } + + context 'when the storage location is not empty' do + let(:storage_location) { create(:storage_location, :with_items, organization: organization) } + + it 'raises an InventoryAlreadyHasItems error' do + expect { + StorageLocation.import_inventory(csv_content, organization.id, storage_location.id) + }.to raise_error(Errors::InventoryAlreadyHasItems) + end + end + + context 'when parsing the CSV file' do + it 'builds line items' do + adjustment = instance_double('Adjustment') + allow(Adjustment).to receive(:new).and_return(adjustment) + expect(adjustment).to receive(:line_items).and_return([]) + StorageLocation.import_inventory(csv_content, organization.id, storage_location.id) + end + + it 'raises a MalformedCSVError for invalid CSV format' do + invalid_csv_content = "quantity;item_name\n10;#{item.name}" + expect { + StorageLocation.import_inventory(invalid_csv_content, organization.id, storage_location.id) + }.to raise_error(CSV::MalformedCSVError) + end + end + + context 'when creating an adjustment' do + it 'calls the AdjustmentCreateService' do + expect(AdjustmentCreateService).to receive(:new).and_call_original + StorageLocation.import_inventory(csv_content, organization.id, storage_location.id) + end + + it 'raises an error if adjustment creation fails' do + allow_any_instance_of(AdjustmentCreateService).to receive(:call).and_return(false) + expect { + StorageLocation.import_inventory(csv_content, organization.id, storage_location.id) + }.to raise_error(StandardError, 'Adjustment creation failed') + end + end + + context 'when all conditions are met' do + it 'successfully imports inventory' do + expect { + StorageLocation.import_inventory(csv_content, organization.id, storage_location.id) + }.to change { storage_location.size }.by(10) + end + end +end +describe "#validate_empty_inventory", :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + + context "when inventory is empty" do + it "does not add an error" do + storage_location.validate_empty_inventory + expect(storage_location.errors[:base]).to be_empty + end + + it "does not throw abort" do + expect { storage_location.validate_empty_inventory }.not_to throw_symbol(:abort) + end + end + + context "when inventory is not empty" do + let(:storage_location_with_items) { create(:storage_location, :with_items, organization: organization) } + + it "adds an error to base" do + storage_location_with_items.validate_empty_inventory + expect(storage_location_with_items.errors[:base]).to include("Cannot delete storage location containing inventory items with non-zero quantities") + end + + it "throws abort" do + expect { storage_location_with_items.validate_empty_inventory }.to throw_symbol(:abort) + end + end +end +describe '.csv_export_headers', :phoenix do + let(:expected_headers) { ["Name", "Address", "Square Footage", "Warehouse Type", "Total Inventory"] } + + it 'returns the correct CSV export headers' do + expect(StorageLocation.csv_export_headers).to eq(expected_headers) + end +end +describe "#generate_csv_from_inventory", :phoenix do + let(:storage_location) { create(:storage_location, :with_items, item_count: 3) } + let(:inventory) { Inventory.create(storage_location: storage_location) } + + context "when storage_locations and inventory are empty" do + let(:storage_locations) { [] } + let(:inventory) { double(all_items: [], quantity_for: 0) } + + it "returns only headers" do + csv = StorageLocation.generate_csv_from_inventory(storage_locations, inventory) + expect(csv).to eq("Name,Address,Square Footage,Warehouse Type,Total Quantity\n") + end + end + + context "when generating CSV headers" do + let(:storage_locations) { [storage_location] } + let(:inventory) { double(all_items: [double(name: 'Item 1', item_id: 1)], quantity_for: 0) } + + it "includes item names in headers" do + csv = StorageLocation.generate_csv_from_inventory(storage_locations, inventory) + expect(csv).to include("Item 1") + end + end + + context "when calculating total quantity for each storage location" do + let(:storage_locations) { [storage_location] } + let(:inventory) { double(all_items: [], quantity_for: 10) } + + it "includes total quantity in CSV" do + csv = StorageLocation.generate_csv_from_inventory(storage_locations, inventory) + expect(csv).to include("10") + end + end + + context "when handling unique items and their quantities" do + let(:storage_locations) { [storage_location] } + let(:inventory) { double(all_items: [double(name: 'Item 1', item_id: 1)], quantity_for: 5) } + + it "includes item quantities in CSV" do + csv = StorageLocation.generate_csv_from_inventory(storage_locations, inventory) + expect(csv).to include("5") + end + end + + context "when normalizing CSV attributes" do + let(:storage_locations) { [storage_location] } + let(:inventory) { double(all_items: [double(name: 'Item 1', item_id: 1)], quantity_for: 5) } + + it "normalizes attributes correctly" do + allow_any_instance_of(StorageLocation).to receive(:normalize_csv_attribute).and_return('Normalized') + csv = StorageLocation.generate_csv_from_inventory(storage_locations, inventory) + expect(csv).to include("Normalized") + end + end + + context "when handling multiple storage locations and items" do + let(:storage_location_2) { create(:storage_location, :with_items, item_count: 2) } + let(:storage_locations) { [storage_location, storage_location_2] } + let(:inventory) { double(all_items: [double(name: 'Item 1', item_id: 1), double(name: 'Item 2', item_id: 2)], quantity_for: 5) } + + it "includes all storage locations and items in CSV" do + csv = StorageLocation.generate_csv_from_inventory(storage_locations, inventory) + expect(csv).to include(storage_location.name, storage_location_2.name, "Item 1", "Item 2") + end + end +end +describe '#empty_inventory?', :phoenix do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:inventory) { View::Inventory.new(organization.id) } + + context 'when inventory is empty' do + it 'returns true' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id).and_return(0) + expect(storage_location.empty_inventory?).to be true + end + end + + context 'when inventory is not empty' do + before do + create(:item, organization: organization) + create(:inventory_item, storage_location: storage_location, quantity: 10) + end + + it 'returns false' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id).and_return(10) + expect(storage_location.empty_inventory?).to be false + end + end + + context 'when there is an error during inventory retrieval' do + it 'does not raise an error' do + allow(inventory).to receive(:quantity_for).with(storage_location: storage_location.id).and_raise(StandardError) + expect { storage_location.empty_inventory? }.not_to raise_error + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/transfer_spec.rb b/phoenix-tests/unit/tests/app/models/transfer_spec.rb new file mode 100644 index 0000000000..f079e703a5 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/transfer_spec.rb @@ -0,0 +1,239 @@ + +require "rails_helper" + +RSpec.describe Transfer do +describe '.csv_export_headers', :phoenix do + it 'returns the correct CSV headers' do + expect(Transfer.csv_export_headers).to eq(['From', 'To', 'Comment', 'Total Moved']) + end +end +describe '#csv_export_attributes', :phoenix do + let(:organization) { create(:organization) } + let(:from_location) { build(:storage_location, name: 'From Location', organization: organization) } + let(:to_location) { build(:storage_location, name: 'To Location', organization: organization) } + let(:line_item) { build(:line_item, quantity: 10, itemizable: transfer) } + let(:transfer) { build(:transfer, from: from_location, to: to_location, comment: 'A comment', organization: organization, line_items: [line_item]) } + + it 'returns attributes when all values are present' do + expect(transfer.csv_export_attributes).to eq(['From Location', 'To Location', 'A comment', 10]) + end + + describe 'when from.name is nil' do + let(:from_location) { build(:storage_location, name: nil, organization: organization) } + + it 'handles nil from.name' do + expect(transfer.csv_export_attributes).to eq([nil, 'To Location', 'A comment', 10]) + end + end + + describe 'when to.name is nil' do + let(:to_location) { build(:storage_location, name: nil, organization: organization) } + + it 'handles nil to.name' do + expect(transfer.csv_export_attributes).to eq(['From Location', nil, 'A comment', 10]) + end + end + + describe 'when comment is nil' do + let(:transfer) { build(:transfer, from: from_location, to: to_location, comment: nil, organization: organization, line_items: [line_item]) } + + it 'defaults comment to none' do + expect(transfer.csv_export_attributes).to eq(['From Location', 'To Location', 'none', 10]) + end + end + + describe 'when line_items.total is nil' do + let(:line_item) { build(:line_item, quantity: nil, itemizable: transfer) } + + it 'handles nil line_items.total' do + expect(transfer.csv_export_attributes).to eq(['From Location', 'To Location', 'A comment', nil]) + end + end + + describe 'when line_items.total is zero' do + let(:line_item) { build(:line_item, quantity: 0, itemizable: transfer) } + + it 'handles zero line_items.total' do + expect(transfer.csv_export_attributes).to eq(['From Location', 'To Location', 'A comment', 0]) + end + end +end +describe "#storage_locations_belong_to_organization", :phoenix do + let(:organization) { build(:organization) } + let(:from_location) { build(:storage_location, organization: organization) } + let(:to_location) { build(:storage_location, organization: organization) } + let(:transfer) { build(:transfer, organization: organization, from: from_location, to: to_location) } + + it "does nothing if organization is nil" do + transfer.organization = nil + transfer.storage_locations_belong_to_organization + expect(transfer.errors[:from]).to be_empty + expect(transfer.errors[:to]).to be_empty + end + + describe "when organization is present" do + it "adds error if 'from' location does not belong to organization" do + transfer.from = build(:storage_location) + transfer.storage_locations_belong_to_organization + expect(transfer.errors[:from]).to include("location must belong to organization") + end + + it "does not add error if 'from' location belongs to organization" do + transfer.storage_locations_belong_to_organization + expect(transfer.errors[:from]).to be_empty + end + + it "adds error if 'to' location does not belong to organization" do + transfer.to = build(:storage_location) + transfer.storage_locations_belong_to_organization + expect(transfer.errors[:to]).to include("location must belong to organization") + end + + it "does not add error if 'to' location belongs to organization" do + transfer.storage_locations_belong_to_organization + expect(transfer.errors[:to]).to be_empty + end + end +end +describe '#storage_locations_must_be_different', :phoenix do + let(:organization) { create(:organization) } + let(:from_location) { build(:storage_location, organization: organization) } + let(:to_location) { build(:storage_location, organization: organization) } + + it 'does not add an error when organization is nil' do + transfer = build(:transfer, organization: nil, from: from_location, to: to_location) + transfer.storage_locations_must_be_different + expect(transfer.errors[:to]).to be_empty + end + + it 'does not add an error when to_id is nil' do + transfer = build(:transfer, organization: organization, from: from_location, to: nil) + transfer.storage_locations_must_be_different + expect(transfer.errors[:to]).to be_empty + end + + it 'adds an error when from_id is equal to to_id' do + transfer = build(:transfer, organization: organization, from: from_location, to: from_location) + transfer.storage_locations_must_be_different + expect(transfer.errors[:to]).to include('location must be different than from location') + end + + it 'does not add an error when from_id is not equal to to_id' do + transfer = build(:transfer, organization: organization, from: from_location, to: to_location) + transfer.storage_locations_must_be_different + expect(transfer.errors[:to]).to be_empty + end +end +describe '#from_storage_quantities', :phoenix do + let(:organization) { create(:organization) } + let(:from_location) { create(:storage_location, organization: organization) } + let(:transfer) { build(:transfer, organization: organization, from: from_location) } + + context 'when organization is nil' do + let(:transfer) { build(:transfer, organization: nil, from: from_location) } + + it 'does not add any errors' do + expect { transfer.from_storage_quantities }.not_to change { transfer.errors[:from] } + end + end + + context 'when from is nil' do + let(:transfer) { build(:transfer, organization: organization, from: nil) } + + it 'does not add any errors' do + expect { transfer.from_storage_quantities }.not_to change { transfer.errors[:from] } + end + end + + describe 'when organization and from are not nil' do + context 'when insufficient_items is empty' do + before do + allow(transfer).to receive(:insufficient_items).and_return([]) + end + + it 'does not add any errors' do + expect { transfer.from_storage_quantities }.not_to change { transfer.errors[:from] } + end + end + + context 'when insufficient_items is not empty' do + let(:item) { create(:item, organization: organization) } + before do + allow(transfer).to receive(:insufficient_items).and_return([item]) + end + + it 'adds an error for insufficient inventory' do + expect { transfer.from_storage_quantities }.to change { transfer.errors[:from] } + .from([]) + .to(include("location has insufficient inventory for #{item.name}")) + end + end + end +end +describe "#insufficient_items", :phoenix do + let(:organization) { create(:organization) } + let(:inventory) { View::Inventory.new(organization.id) } + let(:transfer) { build(:transfer, organization: organization) } + + it "returns an empty array when there are no line items" do + expect(transfer.insufficient_items).to eq([]) + end + + context "when all line items have sufficient inventory" do + let(:transfer) { build(:transfer, :with_items, organization: organization, item_quantity: 10) } + before do + allow(inventory).to receive(:quantity_for).and_return(20) + end + + it "returns an empty array" do + expect(transfer.insufficient_items).to eq([]) + end + end + + context "when some line items have insufficient inventory" do + let(:transfer) { build(:transfer, :with_items, organization: organization, item_quantity: 30) } + before do + allow(inventory).to receive(:quantity_for).and_return(20) + end + + it "returns line items with insufficient inventory" do + expect(transfer.insufficient_items.size).to eq(1) + end + end + + context "when all line items have insufficient inventory" do + let(:transfer) { build(:transfer, :with_items, organization: organization, item_quantity: 30) } + before do + allow(inventory).to receive(:quantity_for).and_return(10) + end + + it "returns all line items" do + expect(transfer.insufficient_items.size).to eq(1) + end + end + + context "when handling edge cases" do + context "like negative quantities" do + let(:transfer) { build(:transfer, :with_items, organization: organization, item_quantity: -10) } + before do + allow(inventory).to receive(:quantity_for).and_return(0) + end + + it "handles negative quantities" do + expect(transfer.insufficient_items).to eq([]) + end + end + + context "like zero quantities" do + let(:transfer) { build(:transfer, :with_items, organization: organization, item_quantity: 0) } + before do + allow(inventory).to receive(:quantity_for).and_return(0) + end + + it "handles zero quantities" do + expect(transfer.insufficient_items).to eq([]) + end + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/unit_spec.rb b/phoenix-tests/unit/tests/app/models/unit_spec.rb new file mode 100644 index 0000000000..1be9d05071 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/unit_spec.rb @@ -0,0 +1,6 @@ + +require "rails_helper" + +RSpec.describe Unit do + +end diff --git a/phoenix-tests/unit/tests/app/models/user_spec.rb b/phoenix-tests/unit/tests/app/models/user_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7d10b82ccbcbfba5c93489d1acff8a2fa8f85bc0 GIT binary patch literal 10017 zcmcIqOOM+|65jLtijI7s2AB{?4!Z%7m$3;JImBy>J-HYL(L=UI9aC(DP0b_0{P%p- zFE*c&Z09f-N$jrr>e*G*ylBK92iXX=YPhU)z7^G8G^>k?>o zlD&cih6>=zmL*!W+K9zkMS?N8v0o*AhNi537#k}mT~IKHGbSEkqINyC6Cf^AFuaAt zvchUnmlr1>;x#i$X1Z-;eK&!wv%&fJ;=Lb@&_QG5NKDn$6YI=7fwy~TY=1uJ5rHM- zr4lf!YFV-6^__5*?kvvnu>R7-%e;#F?sh)n;eHQT{ITvisdEc*jVP>3srMDPVRkf* ziS%>~CqGG!Ou~LG?1v4aZ!)^npGokOYPN5L7Ihn}KMoAw zOk=@JaF9MGQTF4-^ITDAvsWqgznQj{yW9@taTVieAJbPS4@b{fryLvlG~q}7WJ z<=(axWiEEe+p8n_)+`zs%KVzK6(x`##UtPCE0Gpz2Wlh|p4?E@Cs3=Yyh^exQiknj zNLh2PylR6u$UYFqs?~mkUY$-?K2?s)YgVR#hyz9W4<_z}VBZcU{eI>3I{-Aug9AX3 zp=Ut_<;>y=yaxfe=UP9grp#dqSmaT*PsifW9>9L%$k6^l#%2ykje|t;Do568a=&_s zj9>G`Y)F4J$@uW}{sBj#bJKIBU^61mM|@u+soxptboP9lV_K-XyD9$y@q29Rw0r`U%CN37PQ@#?dw|Dczx15X9r0Q9Gr5TVYYARU1?Gb-G7ZK$miRKyP;x3wiAf zkY0~8IEz2Xx|}|IRddH{`KQS*QMmVi4n6FiH*GDNXkqx}nffx*AL|1O=A4&1WE#rP z(dy_?lyksrdU=e(uDD|HZ&~l#fZ~8PeLZovlW7P({`m@rxKTysKSbPZ!#mt=0c5qx z8&wHS>#e^h{-ieAv7SPU*_Lbe=H~U!*ZIqze*NVYqkZAzbJb&{QNu7LQ~PwVGEYN+ zHehEgG+_7t>;FZ}5HZGAW1SBH^o`eNZ#c}cw_2LufAi+&Ya{Ea>`7UD#+LRRXJ@(r z>(?)@-@Y1fb}NT6$DYtV?!cWPX^g7h;=I zgcgn9typLM-$Ue+j_uH^GdTI<+}5+gwId$8^N@V>EDE-6@2$c}&c&9Q*4b$k`)*RU z696J!EQu5atD(HUhR9hK1tq0?y9gi10&#_7hlC;EJ0%ln^t$6*5L(H$IiMNen0cB> z1cS&#mpBT|$#d9X=-Vs}uCeY}+qDvmcsptE57WPZYQrm3u^v_^*0=m#uulT$HE|4@ z0Wghiq_N0KCa&hy6M2>~^Dz`3r=ZjE{1e)s=!k4Yh20R4p4U(pTc1lebh!C?t#ciu zv~ys6jG_i#m#$$s!D&c++qiZ)3(tJ=i=21wXUc9Gu4s&5=*epGtE|) z!A_KN*!r=!eizmD22$`ggcgsf;;N0He%JpE&w-J45MBEZ0G zVuGJ`JAGm@6Z`C6f6=Z~md|lhKtovZhxJTGBfb@+@k5A1EQy9W(@G9lAz!S&9S`jW69s zKQdpIC)yC(;xwtE%_9K^&eaGzqCsWw!}RQg%!Y9)#T~1n-RR3r zw8`H zm$2`?H{EnLTRNQ)|7UQ zcakT_d(2`snC0cSPnRh*@oTd+i)6S=QEMS5(7Obtx}mxzN<4Awn8E%X*!c_5sJIHbpA^{SE)Ij)4FG literal 0 HcmV?d00001 diff --git a/phoenix-tests/unit/tests/app/models/users_role_spec.rb b/phoenix-tests/unit/tests/app/models/users_role_spec.rb new file mode 100644 index 0000000000..e46f6dea0d --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/users_role_spec.rb @@ -0,0 +1,61 @@ + +require "rails_helper" + +RSpec.describe UsersRole do +describe '#current_role_for', :phoenix do + let(:user_with_last_role) { build(:user, last_role: build(:role, name: 'ORG_USER')) } + let(:user_with_roles) { build(:user) } + let(:super_admin_role) { build(:role, name: 'SUPER_ADMIN') } + let(:org_admin_role) { build(:role, name: 'ORG_ADMIN') } + let(:org_user_role) { build(:role, name: 'ORG_USER') } + let(:partner_role) { build(:role, name: 'PARTNER') } + + before do + allow(user_with_roles).to receive(:roles).and_return([partner_role, org_user_role, org_admin_role, super_admin_role]) + end + + it 'returns nil if user is nil' do + expect(UsersRole.current_role_for(nil)).to be_nil + end + + it 'returns last_role if user has a last_role' do + expect(UsersRole.current_role_for(user_with_last_role)).to eq(user_with_last_role.last_role) + end + + describe 'when user has roles' do + it 'returns the first role found in role_order' do + expect(UsersRole.current_role_for(user_with_roles)).to eq(super_admin_role) + end + + it 'returns nil if no roles match the role_order' do + allow(user_with_roles).to receive(:roles).and_return([]) + expect(UsersRole.current_role_for(user_with_roles)).to be_nil + end + end + + it 'returns nil if user has no roles' do + user_without_roles = build(:user) + allow(user_without_roles).to receive(:roles).and_return([]) + expect(UsersRole.current_role_for(user_without_roles)).to be_nil + end +end +describe '#set_last_role_for', :phoenix do + let(:user) { create(:user) } + let(:role) { create(:role) } + + context 'when a UsersRole is found' do + let!(:users_role) { create(:users_role, user: user, role: role) } + + it 'updates the user last_role_id to users_role.id' do + UsersRole.set_last_role_for(user, role) + expect(user.reload.last_role_id).to eq(users_role.id) + end + end + + context 'when no UsersRole is found' do + it 'does not update the user last_role_id' do + expect { UsersRole.set_last_role_for(user, role) }.not_to change { user.reload.last_role_id } + end + end +end +end diff --git a/phoenix-tests/unit/tests/app/models/vendor_spec.rb b/phoenix-tests/unit/tests/app/models/vendor_spec.rb new file mode 100644 index 0000000000..edc6ce4274 --- /dev/null +++ b/phoenix-tests/unit/tests/app/models/vendor_spec.rb @@ -0,0 +1,59 @@ + +require "rails_helper" + +RSpec.describe Vendor do +describe '#volume', :phoenix do + let(:vendor) { create(:vendor) } + + context 'when there are no purchases' do + it 'returns 0' do + expect(vendor.volume).to eq(0) + end + end + + context 'when purchases have no line items' do + let!(:purchase) { create(:purchase, vendor: vendor, line_items: []) } + + it 'returns 0' do + expect(vendor.volume).to eq(0) + end + end + + context 'when line items have zero total' do + let!(:purchase) { create(:purchase, vendor: vendor) } + let!(:line_item) { create(:line_item, quantity: 0, itemizable: purchase) } + + it 'returns 0' do + expect(vendor.volume).to eq(0) + end + end + + context 'when line items have positive totals' do + let!(:purchase) { create(:purchase, vendor: vendor) } + let!(:line_item) { create(:line_item, quantity: 5, itemizable: purchase) } + + it 'returns the correct total' do + expect(vendor.volume).to eq(5) + end + end + + context 'when line items have negative totals' do + let!(:purchase) { create(:purchase, vendor: vendor) } + let!(:line_item) { create(:line_item, quantity: -3, itemizable: purchase) } + + it 'returns the correct total' do + expect(vendor.volume).to eq(-3) + end + end + + context 'when line items have mixed totals' do + let!(:purchase) { create(:purchase, vendor: vendor) } + let!(:line_item1) { create(:line_item, quantity: 5, itemizable: purchase) } + let!(:line_item2) { create(:line_item, quantity: -3, itemizable: purchase) } + + it 'returns the correct total' do + expect(vendor.volume).to eq(2) + end + end +end +end