From c53acd83ccfa3b47adf5cbcf059af9d100fe5650 Mon Sep 17 00:00:00 2001 From: Brian Bonus Date: Thu, 22 May 2025 10:45:17 -0700 Subject: [PATCH 1/3] Bug-5152-update-donations-export-to-include-inactive-unused-items --- .../exports/export_donations_csv_service.rb | 26 +-- .../export_donations_csv_service_spec.rb | 218 +++++++++++++++++- 2 files changed, 225 insertions(+), 19 deletions(-) diff --git a/app/services/exports/export_donations_csv_service.rb b/app/services/exports/export_donations_csv_service.rb index 9ca5bcb73f..da173c8bd6 100644 --- a/app/services/exports/export_donations_csv_service.rb +++ b/app/services/exports/export_donations_csv_service.rb @@ -15,6 +15,13 @@ def initialize(donation_ids:, organization:) ).order(created_at: :asc) @organization = organization + @item_header_names = @organization.items.select("DISTINCT ON (LOWER(name)) items.name").order("LOWER(name) ASC").map(&:name) + + @item_headers = if @organization.include_in_kind_values_in_exported_files + @item_header_names.flat_map { |header| [header, "#{header} In-Kind Value"] } + else + @item_header_names + end end def generate_csv @@ -39,6 +46,7 @@ def generate_csv_data private attr_reader :donations + attr_reader :item_headers def headers # Build the headers in the correct order @@ -95,24 +103,6 @@ def base_headers base_table.keys end - def item_headers - return @item_headers if @item_headers - - item_names = Set.new - - donations.each do |donation| - donation.line_items.each do |line_item| - item_names.add(line_item.item.name) - end - end - - @item_headers = item_names.sort - - @item_headers = @item_headers.flat_map { |header| [header, "#{header} In-Kind Value"] } if @organization.include_in_kind_values_in_exported_files - - @item_headers - end - def build_row_data(donation) row = base_table.values.map { |closure| closure.call(donation) } row += make_item_quantity_and_value_slots diff --git a/spec/services/exports/export_donations_csv_service_spec.rb b/spec/services/exports/export_donations_csv_service_spec.rb index 00c8cb152b..25647baf3c 100644 --- a/spec/services/exports/export_donations_csv_service_spec.rb +++ b/spec/services/exports/export_donations_csv_service_spec.rb @@ -73,9 +73,11 @@ def source_name(donation) end context 'while "Include in-kind value in donation and distribution exports?" is set to yes' do - it 'should match the expected content with in-kind value of each item for the csv' do + before do allow(organization).to receive(:include_in_kind_values_in_exported_files).and_return(true) + end + it 'should match the expected content with in-kind value of each item for the csv' do csv = <<~CSV Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,A Item In-Kind Value,B Item,B Item In-Kind Value,C Item,C Item In-Kind Value,Dupe Item,Dupe Item In-Kind Value,E Item,E Item In-Kind Value Product Drive,2025-01-01,#{source_name(donations[0])},#{storage_location.name},15,2,94.0,It's a fine day for diapers.,7,70.00,0,0.00,0,0.00,8,24.00,0,0.00 @@ -85,6 +87,220 @@ def source_name(donation) CSV expect(subject).to eq(csv) end + + it 'should include inactive items in the export' do + inactive_item = create(:item, :inactive, name: "Inactive Item", organization: organization) + csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data + + # Verify the inactive item appears in headers + expect(csv_data[0]).to include(inactive_item.name) + expect(csv_data[0]).to include("#{inactive_item.name} In-Kind Value") + + # Verify all rows have 0 quantity for the inactive item + inactive_item_index = csv_data[0].index(inactive_item.name) + csv_data[1..].each do |row| + expect(row[inactive_item_index]).to eq(0) + expect(row[inactive_item_index + 1]).to eq(0) + end + end + + it 'should include items that are not in any donation' do + unused_item = create(:item, name: "Unused Item", organization: organization) + csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data + + # Verify the unused item appears in headers + expect(csv_data[0]).to include(unused_item.name) + expect(csv_data[0]).to include("#{unused_item.name} In-Kind Value") + + # Verify all rows have 0 quantity for the unused item + unused_item_index = csv_data[0].index(unused_item.name) + csv_data[1..].each do |row| + expect(row[unused_item_index]).to eq(0) + expect(row[unused_item_index + 1]).to eq(0) + end + end + end + end + + describe '#generate_csv_data' do + let(:organization) { create(:organization) } + let(:generated_csv_data) { described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data } + let(:donation_ids) { donations.map(&:id) } + let(:duplicate_item) { create(:item, organization: organization) } + let(:items_lists) do + [ + [ + [duplicate_item, 5], + [create(:item, organization: organization), 7], + [duplicate_item, 3] + ], + *(Array.new(3) do |i| + [[create( + :item, name: "item_#{i}", organization: organization + ), i + 1]] + end) + ] + end + + let(:base_headers) do + described_class.new(donation_ids: [], organization: organization).send(:base_headers) + end + + let(:item_names) { items_lists.flatten(1).map(&:first).map(&:name).sort.uniq } + + let(:donations) do + start_time = Time.current + + items_lists.each_with_index.map do |items, i| + donation = create( + :donation, + organization: organization, + donation_site: create( + :donation_site, name: "Space Needle #{i}", organization: organization + ), + issued_at: start_time + i.days, + comment: "This is the #{i}-th donation in the test." + ) + + items.each do |(item, quantity)| + donation.line_items << create( + :line_item, quantity: quantity, item: item + ) + end + + donation + end + end + + let(:expected_headers) do + [ + "Source", + "Date", + "Details", + "Storage Location", + "Quantity of Items", + "Variety of Items", + "In-Kind Total", + "Comments" + ] + expected_item_headers + end + + let(:total_item_quantities) do + template = item_names.index_with(0) + + items_lists.map do |items_list| + row = template.dup + items_list.each do |(item, quantity)| + row[item.name] += quantity + end + row.values + end + end + + let(:expected_item_headers) do + expect(item_names).not_to be_empty + + item_names + end + + it 'should match the expected content for the csv' do + expect(generated_csv_data[0]).to eq(expected_headers) + + donations.zip(total_item_quantities).each_with_index do |(donation, total_item_quantity), idx| + row = [ + donation.source, + donation.issued_at.strftime("%F"), + donation.details, + donation.storage_view, + donation.line_items.total, + total_item_quantity.count(&:positive?), + donation.in_kind_value_money, + donation.comment + ] + + row += total_item_quantity + + expect(generated_csv_data[idx + 1]).to eq(row) + end + end + + context 'when an organization\'s item exists but isn\'t in any donation' do + let(:unused_item) { create(:item, name: "Unused Item", organization: organization) } + let(:generated_csv_data) do + # Force unused_item to be created first + unused_item + described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data + end + + it 'should include the unused item as a column with 0 quantities' do + expect(generated_csv_data[0]).to include(unused_item.name) + + donations.each_with_index do |_, idx| + row = generated_csv_data[idx + 1] + item_column_index = generated_csv_data[0].index(unused_item.name) + expect(row[item_column_index]).to eq(0) + end + end + end + + context 'when an organization\'s item is inactive' do + let(:inactive_item) { create(:item, name: "Inactive Item", organization: organization, active: false) } + let(:generated_csv_data) do + # Force inactive_item to be created first + inactive_item + described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data + end + + it 'should include the inactive item as a column with 0 quantities' do + expect(generated_csv_data[0]).to include(inactive_item.name) + + donations.each_with_index do |_, idx| + row = generated_csv_data[idx + 1] + item_column_index = generated_csv_data[0].index(inactive_item.name) + expect(row[item_column_index]).to eq(0) + end + end + end + + context 'when generating CSV output' do + let(:generated_csv) { described_class.new(donation_ids: donation_ids, organization: organization).generate_csv } + + it 'returns a valid CSV string' do + expect(generated_csv).to be_a(String) + expect { CSV.parse(generated_csv) }.not_to raise_error + end + + it 'includes headers as first row' do + csv_rows = CSV.parse(generated_csv) + expect(csv_rows.first).to eq(expected_headers) + end + + it 'includes data for all donations' do + csv_rows = CSV.parse(generated_csv) + expect(csv_rows.count).to eq(donations.count + 1) # +1 for headers + end + end + + context 'when items have different cases' do + let(:item_names) { ["Zebra", "apple", "Banana"] } + let(:expected_order) { ["apple", "Banana", "Zebra"] } + let(:donation) { create(:donation, organization: organization) } + let(:case_sensitive_csv_data) do + # Create items in random order to ensure sort is working + item_names.shuffle.each do |name| + create(:item, name: name, organization: organization) + end + + described_class.new(donation_ids: [donation.id], organization: organization).generate_csv_data + end + + it 'should sort item columns case-insensitively, ASC' do + # Get just the item columns by removing the known base headers + item_columns = case_sensitive_csv_data[0] - base_headers + + # Check that the remaining columns match our expected case-insensitive sort + expect(item_columns).to eq(expected_order) + end end end end From bddedf422693199adbf68facebc3b7e973d7686f Mon Sep 17 00:00:00 2001 From: Brian Bonus Date: Thu, 29 May 2025 12:43:16 -0700 Subject: [PATCH 2/3] bug-5165 Cleans up class & spec for pr review --- .../exports/export_donations_csv_service.rb | 11 +++-- .../export_donations_csv_service_spec.rb | 40 ++++++++----------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/app/services/exports/export_donations_csv_service.rb b/app/services/exports/export_donations_csv_service.rb index da173c8bd6..8b5629f538 100644 --- a/app/services/exports/export_donations_csv_service.rb +++ b/app/services/exports/export_donations_csv_service.rb @@ -15,12 +15,12 @@ def initialize(donation_ids:, organization:) ).order(created_at: :asc) @organization = organization - @item_header_names = @organization.items.select("DISTINCT ON (LOWER(name)) items.name").order("LOWER(name) ASC").map(&:name) + item_header_names = @organization.items.select("DISTINCT ON (LOWER(name)) items.name").order("LOWER(name) ASC").map(&:name) @item_headers = if @organization.include_in_kind_values_in_exported_files - @item_header_names.flat_map { |header| [header, "#{header} In-Kind Value"] } + item_header_names.flat_map { |header| [header, "#{header} In-Kind Value"] } else - @item_header_names + item_header_names end end @@ -46,11 +46,10 @@ def generate_csv_data private attr_reader :donations - attr_reader :item_headers def headers # Build the headers in the correct order - base_headers + item_headers + base_headers + @item_headers end # Returns a Hash of keys to indexes so that obtaining the index @@ -118,7 +117,7 @@ def build_row_data(donation) end def make_item_quantity_and_value_slots - slots = Array.new(item_headers.size, 0) + slots = Array.new(@item_headers.size, 0) slots = slots.map.with_index { |value, index| index.odd? ? Money.new(0) : value } if @organization.include_in_kind_values_in_exported_files slots end diff --git a/spec/services/exports/export_donations_csv_service_spec.rb b/spec/services/exports/export_donations_csv_service_spec.rb index 25647baf3c..103ad571d2 100644 --- a/spec/services/exports/export_donations_csv_service_spec.rb +++ b/spec/services/exports/export_donations_csv_service_spec.rb @@ -93,14 +93,13 @@ def source_name(donation) csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data # Verify the inactive item appears in headers - expect(csv_data[0]).to include(inactive_item.name) - expect(csv_data[0]).to include("#{inactive_item.name} In-Kind Value") + expect(csv_data[0][18]).to eq(inactive_item.name) + expect(csv_data[0][19]).to eq("#{inactive_item.name} In-Kind Value") # Verify all rows have 0 quantity for the inactive item - inactive_item_index = csv_data[0].index(inactive_item.name) csv_data[1..].each do |row| - expect(row[inactive_item_index]).to eq(0) - expect(row[inactive_item_index + 1]).to eq(0) + expect(row[18]).to eq(0) + expect(row[19]).to eq(0) end end @@ -109,14 +108,13 @@ def source_name(donation) csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data # Verify the unused item appears in headers - expect(csv_data[0]).to include(unused_item.name) - expect(csv_data[0]).to include("#{unused_item.name} In-Kind Value") + expect(csv_data[0][18]).to include(unused_item.name) + expect(csv_data[0][19]).to include("#{unused_item.name} In-Kind Value") # Verify all rows have 0 quantity for the unused item - unused_item_index = csv_data[0].index(unused_item.name) csv_data[1..].each do |row| - expect(row[unused_item_index]).to eq(0) - expect(row[unused_item_index + 1]).to eq(0) + expect(row[18]).to eq(0) + expect(row[19]).to eq(0) end end end @@ -225,39 +223,33 @@ def source_name(donation) end context 'when an organization\'s item exists but isn\'t in any donation' do - let(:unused_item) { create(:item, name: "Unused Item", organization: organization) } - let(:generated_csv_data) do - # Force unused_item to be created first - unused_item + let!(:unused_item) { create(:item, name: "Unused Item", organization: organization) } + let!(:generated_csv_data) do described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data end it 'should include the unused item as a column with 0 quantities' do - expect(generated_csv_data[0]).to include(unused_item.name) + expect(generated_csv_data[0][13]).to eq(unused_item.name) donations.each_with_index do |_, idx| row = generated_csv_data[idx + 1] - item_column_index = generated_csv_data[0].index(unused_item.name) - expect(row[item_column_index]).to eq(0) + expect(row[13]).to eq(0) end end end context 'when an organization\'s item is inactive' do - let(:inactive_item) { create(:item, name: "Inactive Item", organization: organization, active: false) } - let(:generated_csv_data) do - # Force inactive_item to be created first - inactive_item + let!(:inactive_item) { create(:item, name: "Inactive Item", organization: organization, active: false) } + let!(:generated_csv_data) do described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data end it 'should include the inactive item as a column with 0 quantities' do - expect(generated_csv_data[0]).to include(inactive_item.name) + expect(generated_csv_data[0][10]).to include(inactive_item.name) donations.each_with_index do |_, idx| row = generated_csv_data[idx + 1] - item_column_index = generated_csv_data[0].index(inactive_item.name) - expect(row[item_column_index]).to eq(0) + expect(row[10]).to eq(0) end end end From 5d67da6a627962c6622107a1834674d0b4ad7bc8 Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sun, 19 Jul 2026 11:38:15 -0400 Subject: [PATCH 3/3] Switch donations export spec to CSV fixture comparisons Per review: compare the full generated CSV against fixture files in spec/fixtures/files/ instead of order-independent header/index lookups, so any change to column order or content fails loudly (a CSV header reorder is a breaking change for consumers). - Pin the product drive / manufacturer / donation site names in the factories so the fixtures can be fully static, replacing the source_name interpolation helper - Rewrite the new inactive-item and unused-item tests (bug #5152 coverage) as full-CSV fixture comparisons - Fold the #generate_csv_data describe block's unique coverage (case-insensitive column sort) into a hardcoded fixture test and drop the rest, which duplicated the fixture assertions in loose form Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BNy2e1J81K8xbvXraCeN8e --- spec/fixtures/files/donations_export.csv | 5 + ...donations_export_case_insensitive_sort.csv | 2 + .../donations_export_with_in_kind_values.csv | 5 + .../donations_export_with_inactive_item.csv | 5 + .../donations_export_with_unused_item.csv | 5 + .../export_donations_csv_service_spec.rb | 265 +++--------------- 6 files changed, 61 insertions(+), 226 deletions(-) create mode 100644 spec/fixtures/files/donations_export.csv create mode 100644 spec/fixtures/files/donations_export_case_insensitive_sort.csv create mode 100644 spec/fixtures/files/donations_export_with_in_kind_values.csv create mode 100644 spec/fixtures/files/donations_export_with_inactive_item.csv create mode 100644 spec/fixtures/files/donations_export_with_unused_item.csv diff --git a/spec/fixtures/files/donations_export.csv b/spec/fixtures/files/donations_export.csv new file mode 100644 index 0000000000..cb965506f3 --- /dev/null +++ b/spec/fixtures/files/donations_export.csv @@ -0,0 +1,5 @@ +Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,B Item,C Item,Dupe Item,E Item +Product Drive,2025-01-01,Test Product Drive,Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,0,0,8,0 +Manufacturer,2025-01-01,Test Manufacturer,Test Storage Location,1,1,20.0,It's a fine day for diapers.,0,1,0,0,0 +Donation Site,2025-01-01,Test Donation Site,Test Storage Location,2,1,60.0,It's a fine day for diapers.,0,0,2,0,0 +Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,3,1,120.0,It's a fine day for diapers.,0,0,0,0,3 diff --git a/spec/fixtures/files/donations_export_case_insensitive_sort.csv b/spec/fixtures/files/donations_export_case_insensitive_sort.csv new file mode 100644 index 0000000000..3ee4652516 --- /dev/null +++ b/spec/fixtures/files/donations_export_case_insensitive_sort.csv @@ -0,0 +1,2 @@ +Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,apple,Banana,Zebra +Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,2,1,3.0,It's a fine day for diapers.,0,2,0 diff --git a/spec/fixtures/files/donations_export_with_in_kind_values.csv b/spec/fixtures/files/donations_export_with_in_kind_values.csv new file mode 100644 index 0000000000..1e8507cee0 --- /dev/null +++ b/spec/fixtures/files/donations_export_with_in_kind_values.csv @@ -0,0 +1,5 @@ +Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,A Item In-Kind Value,B Item,B Item In-Kind Value,C Item,C Item In-Kind Value,Dupe Item,Dupe Item In-Kind Value,E Item,E Item In-Kind Value +Product Drive,2025-01-01,Test Product Drive,Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,70.00,0,0.00,0,0.00,8,24.00,0,0.00 +Manufacturer,2025-01-01,Test Manufacturer,Test Storage Location,1,1,20.0,It's a fine day for diapers.,0,0.00,1,20.00,0,0.00,0,0.00,0,0.00 +Donation Site,2025-01-01,Test Donation Site,Test Storage Location,2,1,60.0,It's a fine day for diapers.,0,0.00,0,0.00,2,60.00,0,0.00,0,0.00 +Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,3,1,120.0,It's a fine day for diapers.,0,0.00,0,0.00,0,0.00,0,0.00,3,120.00 diff --git a/spec/fixtures/files/donations_export_with_inactive_item.csv b/spec/fixtures/files/donations_export_with_inactive_item.csv new file mode 100644 index 0000000000..c616901b9d --- /dev/null +++ b/spec/fixtures/files/donations_export_with_inactive_item.csv @@ -0,0 +1,5 @@ +Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,A Item In-Kind Value,B Item,B Item In-Kind Value,C Item,C Item In-Kind Value,Dupe Item,Dupe Item In-Kind Value,E Item,E Item In-Kind Value,Inactive Item,Inactive Item In-Kind Value +Product Drive,2025-01-01,Test Product Drive,Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,70.00,0,0.00,0,0.00,8,24.00,0,0.00,0,0.00 +Manufacturer,2025-01-01,Test Manufacturer,Test Storage Location,1,1,20.0,It's a fine day for diapers.,0,0.00,1,20.00,0,0.00,0,0.00,0,0.00,0,0.00 +Donation Site,2025-01-01,Test Donation Site,Test Storage Location,2,1,60.0,It's a fine day for diapers.,0,0.00,0,0.00,2,60.00,0,0.00,0,0.00,0,0.00 +Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,3,1,120.0,It's a fine day for diapers.,0,0.00,0,0.00,0,0.00,0,0.00,3,120.00,0,0.00 diff --git a/spec/fixtures/files/donations_export_with_unused_item.csv b/spec/fixtures/files/donations_export_with_unused_item.csv new file mode 100644 index 0000000000..b91505deb0 --- /dev/null +++ b/spec/fixtures/files/donations_export_with_unused_item.csv @@ -0,0 +1,5 @@ +Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,A Item In-Kind Value,B Item,B Item In-Kind Value,C Item,C Item In-Kind Value,Dupe Item,Dupe Item In-Kind Value,E Item,E Item In-Kind Value,Unused Item,Unused Item In-Kind Value +Product Drive,2025-01-01,Test Product Drive,Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,70.00,0,0.00,0,0.00,8,24.00,0,0.00,0,0.00 +Manufacturer,2025-01-01,Test Manufacturer,Test Storage Location,1,1,20.0,It's a fine day for diapers.,0,0.00,1,20.00,0,0.00,0,0.00,0,0.00,0,0.00 +Donation Site,2025-01-01,Test Donation Site,Test Storage Location,2,1,60.0,It's a fine day for diapers.,0,0.00,0,0.00,2,60.00,0,0.00,0,0.00,0,0.00 +Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,3,1,120.0,It's a fine day for diapers.,0,0.00,0,0.00,0,0.00,0,0.00,3,120.00,0,0.00 diff --git a/spec/services/exports/export_donations_csv_service_spec.rb b/spec/services/exports/export_donations_csv_service_spec.rb index fc21b6ff14..2d9dc2c5cc 100644 --- a/spec/services/exports/export_donations_csv_service_spec.rb +++ b/spec/services/exports/export_donations_csv_service_spec.rb @@ -7,10 +7,11 @@ let(:donation_ids) { donations.map(&:id) } let(:duplicate_item) { create(:item, name: "Dupe Item", value_in_cents: 300, organization: organization) } - let(:donation_items_and_quantities) { + let(:donation_definitions) { [ { - source: :product_drive_donation, + factory: :product_drive_donation, + attributes: {product_drive: create(:product_drive, name: "Test Product Drive", organization: organization)}, items: [ [duplicate_item, 5], [create(:item, name: "A Item", value_in_cents: 1000, organization: organization), 7], @@ -18,58 +19,49 @@ ] }, { - source: :manufacturer_donation, + factory: :manufacturer_donation, + attributes: {manufacturer: create(:manufacturer, name: "Test Manufacturer", organization: organization)}, items: [[create(:item, name: "B Item", value_in_cents: 2000, organization: organization), 1]] }, { - source: :donation_site_donation, + factory: :donation_site_donation, + attributes: {donation_site: create(:donation_site, name: "Test Donation Site", organization: organization)}, items: [[create(:item, name: "C Item", value_in_cents: 3000, organization: organization), 2]] }, { - source: :donation, + factory: :donation, + attributes: {}, items: [[create(:item, name: "E Item", value_in_cents: 4000, organization: organization), 3]] } ] } let(:donations) do - donation_items_and_quantities.each_with_index.map do |items_quantities, i| + donation_definitions.map do |definition| donation = create( - items_quantities[:source], + definition[:factory], storage_location: storage_location, organization: organization, issued_at: "2025-01-01", - comment: "It's a fine day for diapers." + comment: "It's a fine day for diapers.", + **definition[:attributes] ) - items_quantities[:items].each do |line_item| - donation.line_items << create(:line_item, item: line_item[0], quantity: line_item[1]) + definition[:items].each do |(item, quantity)| + donation.line_items << create(:line_item, item: item, quantity: quantity) end donation end end - def source_name(donation) - if !donation.product_drive.nil? - donation.product_drive.name - elsif !donation.manufacturer.nil? - donation.manufacturer.name - elsif !donation.donation_site.nil? - donation.donation_site.name - end + def expected_csv(fixture_name) + Rails.root.join("spec/fixtures/files", fixture_name).read end context 'while "Include in-kind value in donation and distribution exports?" is set to no' do it 'should match the expected content without in-kind value of each item for the csv' do - csv = <<~CSV - Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,B Item,C Item,Dupe Item,E Item - Product Drive,2025-01-01,#{source_name(donations[0])},Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,0,0,8,0 - Manufacturer,2025-01-01,#{source_name(donations[1])},Test Storage Location,1,1,20.0,It's a fine day for diapers.,0,1,0,0,0 - Donation Site,2025-01-01,#{source_name(donations[2])},Test Storage Location,2,1,60.0,It's a fine day for diapers.,0,0,2,0,0 - Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,3,1,120.0,It's a fine day for diapers.,0,0,0,0,3 - CSV - expect(subject).to eq(csv) + expect(subject).to eq(expected_csv("donations_export.csv")) end end @@ -79,220 +71,41 @@ def source_name(donation) end it 'should match the expected content with in-kind value of each item for the csv' do - csv = <<~CSV - Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,A Item In-Kind Value,B Item,B Item In-Kind Value,C Item,C Item In-Kind Value,Dupe Item,Dupe Item In-Kind Value,E Item,E Item In-Kind Value - Product Drive,2025-01-01,#{source_name(donations[0])},Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,70.00,0,0.00,0,0.00,8,24.00,0,0.00 - Manufacturer,2025-01-01,#{source_name(donations[1])},Test Storage Location,1,1,20.0,It's a fine day for diapers.,0,0.00,1,20.00,0,0.00,0,0.00,0,0.00 - Donation Site,2025-01-01,#{source_name(donations[2])},Test Storage Location,2,1,60.0,It's a fine day for diapers.,0,0.00,0,0.00,2,60.00,0,0.00,0,0.00 - Misc. Donation,2025-01-01,It's a fine day for...,Test Storage Location,3,1,120.0,It's a fine day for diapers.,0,0.00,0,0.00,0,0.00,0,0.00,3,120.00 - CSV - expect(subject).to eq(csv) + expect(subject).to eq(expected_csv("donations_export_with_in_kind_values.csv")) end - it 'should include inactive items in the export' do - inactive_item = create(:item, :inactive, name: "Inactive Item", organization: organization) - csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data - - # Verify the inactive item appears in headers - expect(csv_data[0][18]).to eq(inactive_item.name) - expect(csv_data[0][19]).to eq("#{inactive_item.name} In-Kind Value") + it 'should include inactive items in the export with zero quantities' do + create(:item, :inactive, name: "Inactive Item", organization: organization) - # Verify all rows have 0 quantity for the inactive item - csv_data[1..].each do |row| - expect(row[18]).to eq(0) - expect(row[19]).to eq(0) - end + expect(subject).to eq(expected_csv("donations_export_with_inactive_item.csv")) end - it 'should include items that are not in any donation' do - unused_item = create(:item, name: "Unused Item", organization: organization) - csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data + it 'should include items that are not in any donation with zero quantities' do + create(:item, name: "Unused Item", organization: organization) - # Verify the unused item appears in headers - expect(csv_data[0][18]).to include(unused_item.name) - expect(csv_data[0][19]).to include("#{unused_item.name} In-Kind Value") - - # Verify all rows have 0 quantity for the unused item - csv_data[1..].each do |row| - expect(row[18]).to eq(0) - expect(row[19]).to eq(0) - end + expect(subject).to eq(expected_csv("donations_export_with_unused_item.csv")) end end - end - describe '#generate_csv_data' do - let(:organization) { create(:organization) } - let(:generated_csv_data) { described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data } - let(:donation_ids) { donations.map(&:id) } - let(:duplicate_item) { create(:item, organization: organization) } - let(:items_lists) do - [ + context 'when item names differ only by case' do + let(:donation_definitions) { [ - [duplicate_item, 5], - [create(:item, organization: organization), 7], - [duplicate_item, 3] - ], - *(Array.new(3) do |i| - [[create( - :item, name: "item_#{i}", organization: organization - ), i + 1]] - end) - ] - end - - let(:base_headers) do - described_class.new(donation_ids: [], organization: organization).send(:base_headers) - end - - let(:item_names) { items_lists.flatten(1).map(&:first).map(&:name).sort.uniq } - - let(:donations) do - start_time = Time.current - - items_lists.each_with_index.map do |items, i| - donation = create( - :donation, - organization: organization, - donation_site: create( - :donation_site, name: "Space Needle #{i}", organization: organization - ), - issued_at: start_time + i.days, - comment: "This is the #{i}-th donation in the test." - ) - - items.each do |(item, quantity)| - donation.line_items << create( - :line_item, quantity: quantity, item: item - ) - end - - donation - end - end - - let(:expected_headers) do - [ - "Source", - "Date", - "Details", - "Storage Location", - "Quantity of Items", - "Variety of Items", - "In-Kind Total", - "Comments" - ] + expected_item_headers - end - - let(:total_item_quantities) do - template = item_names.index_with(0) - - items_lists.map do |items_list| - row = template.dup - items_list.each do |(item, quantity)| - row[item.name] += quantity - end - row.values - end - end - - let(:expected_item_headers) do - expect(item_names).not_to be_empty - - item_names - end - - it 'should match the expected content for the csv' do - expect(generated_csv_data[0]).to eq(expected_headers) - - donations.zip(total_item_quantities).each_with_index do |(donation, total_item_quantity), idx| - row = [ - donation.source, - donation.issued_at.strftime("%F"), - donation.details, - donation.storage_view, - donation.line_items.total, - total_item_quantity.count(&:positive?), - donation.in_kind_value_money, - donation.comment + { + factory: :donation, + attributes: {}, + items: [[create(:item, name: "Banana", value_in_cents: 150, organization: organization), 2]] + } ] - - row += total_item_quantity - - expect(generated_csv_data[idx + 1]).to eq(row) - end - end - - context 'when an organization\'s item exists but isn\'t in any donation' do - let!(:unused_item) { create(:item, name: "Unused Item", organization: organization) } - let!(:generated_csv_data) do - described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data - end - - it 'should include the unused item as a column with 0 quantities' do - expect(generated_csv_data[0][13]).to eq(unused_item.name) - - donations.each_with_index do |_, idx| - row = generated_csv_data[idx + 1] - expect(row[13]).to eq(0) - end - end - end - - context 'when an organization\'s item is inactive' do - let!(:inactive_item) { create(:item, name: "Inactive Item", organization: organization, active: false) } - let!(:generated_csv_data) do - described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data - end - - it 'should include the inactive item as a column with 0 quantities' do - expect(generated_csv_data[0][10]).to include(inactive_item.name) - - donations.each_with_index do |_, idx| - row = generated_csv_data[idx + 1] - expect(row[10]).to eq(0) - end - end - end - - context 'when generating CSV output' do - let(:generated_csv) { described_class.new(donation_ids: donation_ids, organization: organization).generate_csv } - - it 'returns a valid CSV string' do - expect(generated_csv).to be_a(String) - expect { CSV.parse(generated_csv) }.not_to raise_error - end - - it 'includes headers as first row' do - csv_rows = CSV.parse(generated_csv) - expect(csv_rows.first).to eq(expected_headers) - end - - it 'includes data for all donations' do - csv_rows = CSV.parse(generated_csv) - expect(csv_rows.count).to eq(donations.count + 1) # +1 for headers - end - end - - context 'when items have different cases' do - let(:item_names) { ["Zebra", "apple", "Banana"] } - let(:expected_order) { ["apple", "Banana", "Zebra"] } - let(:donation) { create(:donation, organization: organization) } - let(:case_sensitive_csv_data) do - # Create items in random order to ensure sort is working - item_names.shuffle.each do |name| - create(:item, name: name, organization: organization) - end - - described_class.new(donation_ids: [donation.id], organization: organization).generate_csv_data - end + } it 'should sort item columns case-insensitively, ASC' do - # Get just the item columns by removing the known base headers - item_columns = case_sensitive_csv_data[0] - base_headers + # Create the other items in reverse-ASCII order to prove the sort is + # case-insensitive rather than relying on creation order or ASCII order + # (which would put "Zebra" before "apple"). + create(:item, name: "apple", organization: organization) + create(:item, name: "Zebra", organization: organization) - # Check that the remaining columns match our expected case-insensitive sort - expect(item_columns).to eq(expected_order) + expect(subject).to eq(expected_csv("donations_export_case_insensitive_sort.csv")) end end end