From 133f5b827198ed43b0259ef34e1ebfd4738df459 Mon Sep 17 00:00:00 2001 From: Cassandra Wallace Date: Sat, 7 Mar 2026 14:00:19 +0200 Subject: [PATCH 1/5] Add validation to prevent zero quantity line items in purchases (#5507) --- app/models/purchase.rb | 5 +++++ spec/models/purchase_spec.rb | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/app/models/purchase.rb b/app/models/purchase.rb index 0b75156296..b86d6a153c 100644 --- a/app/models/purchase.rb +++ b/app/models/purchase.rb @@ -59,6 +59,7 @@ class Purchase < ApplicationRecord validates :amount_spent_in_cents, numericality: { greater_than: 0 } validate :total_equal_to_all_categories + validate :line_items_quantity_is_positive before_destroy :check_no_intervening_snapshot validates :amount_spent_on_diapers_cents, numericality: { greater_than_or_equal_to: 0 } @@ -140,4 +141,8 @@ def check_no_intervening_snapshot raise "We can't delete purchases entered before #{intervening.event_time.to_date}." end end + + def line_items_quantity_is_positive + line_items_quantity_is_at_least(1) + end end diff --git a/spec/models/purchase_spec.rb b/spec/models/purchase_spec.rb index 4dfee762a2..a68899a4ab 100644 --- a/spec/models/purchase_spec.rb +++ b/spec/models/purchase_spec.rb @@ -65,6 +65,13 @@ expect(d).not_to be_valid end + it "is not valid if any line item has zero quantity" do + item = create(:item) + p = build(:purchase) + p.line_items.build(item_id: item.id, quantity: 0) + expect(p).not_to be_valid + end + it "is valid if all categories are positive and add up to the total" do d = build(:purchase, amount_spent_in_cents: 1150, amount_spent_on_diapers_cents: 200, From e6cfb13205387a2c72532e2070aa66debd03058a Mon Sep 17 00:00:00 2001 From: Cassandra Wallace Date: Mon, 9 Mar 2026 22:50:46 +0200 Subject: [PATCH 2/5] Fix existing test that created purchase w/ 0 quantity items --- spec/queries/low_inventory_query_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/queries/low_inventory_query_spec.rb b/spec/queries/low_inventory_query_spec.rb index fd7454e851..5462607b01 100644 --- a/spec/queries/low_inventory_query_spec.rb +++ b/spec/queries/low_inventory_query_spec.rb @@ -34,7 +34,10 @@ context "when minimum_quantity is 0 and recommended_quantity is nil and item quantity is 0" do let(:item) { create :item, organization: organization } let(:minimum_quantity) { 0 } - let(:inventory_item_quantity) { 0 } + + before do + TestInventory.create_inventory(organization, { storage_location.id => { item.id => 0 } }) + end it { is_expected.to eq [] } end From 03cd0710f6551e0957c7731fe286320cb790909a Mon Sep 17 00:00:00 2001 From: Cassandra Wallace Date: Mon, 9 Mar 2026 22:58:54 +0200 Subject: [PATCH 3/5] Add test for neg quantity in purchase line items (#5507) --- spec/models/purchase_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/models/purchase_spec.rb b/spec/models/purchase_spec.rb index a68899a4ab..8accae1383 100644 --- a/spec/models/purchase_spec.rb +++ b/spec/models/purchase_spec.rb @@ -72,6 +72,13 @@ expect(p).not_to be_valid end + it "is not valid if any line item has negative quantity" do + item = create(:item) + p = build(:purchase) + p.line_items.build(item_id: item.id, quantity: -1) + expect(p).not_to be_valid + end + it "is valid if all categories are positive and add up to the total" do d = build(:purchase, amount_spent_in_cents: 1150, amount_spent_on_diapers_cents: 200, From 644e2e50d4ccae939424e82d1590ea45f3c36373 Mon Sep 17 00:00:00 2001 From: Cassandra Wallace Date: Sun, 15 Mar 2026 23:32:25 +0200 Subject: [PATCH 4/5] Fix lint issue & add comment to LowInventoryQuery test --- spec/queries/low_inventory_query_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/queries/low_inventory_query_spec.rb b/spec/queries/low_inventory_query_spec.rb index 5462607b01..213e43d66d 100644 --- a/spec/queries/low_inventory_query_spec.rb +++ b/spec/queries/low_inventory_query_spec.rb @@ -35,8 +35,10 @@ let(:item) { create :item, organization: organization } let(:minimum_quantity) { 0 } + # Use TestInventory to set up inventory directly, instead of creating + # a purchase with 0 quantity items (which our validation now rejects). before do - TestInventory.create_inventory(organization, { storage_location.id => { item.id => 0 } }) + TestInventory.create_inventory(organization, {storage_location.id => {item.id => 0}}) end it { is_expected.to eq [] } From 89753cf5dfe7253217394aed2be00b59838e3ae7 Mon Sep 17 00:00:00 2001 From: Jane Wheatley Date: Sun, 22 Mar 2026 08:12:00 -0700 Subject: [PATCH 5/5] Refactor low inventory spec --- spec/queries/low_inventory_query_spec.rb | 116 ++++++++++++----------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/spec/queries/low_inventory_query_spec.rb b/spec/queries/low_inventory_query_spec.rb index 213e43d66d..3e5a1e125a 100644 --- a/spec/queries/low_inventory_query_spec.rb +++ b/spec/queries/low_inventory_query_spec.rb @@ -1,12 +1,10 @@ RSpec.describe LowInventoryQuery do - subject { LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } } - let(:organization) { create :organization } let(:storage_location) { create :storage_location, organization: organization } let(:minimum_quantity) { 0 } let(:recommended_quantity) { 0 } - let(:inventory_item_quantity) { 100 } + let(:current_quantity) { 100 } let(:item) do create :item, @@ -15,106 +13,116 @@ on_hand_recommended_quantity: recommended_quantity end - let!(:purchase) { - create :purchase, - :with_items, - organization: organization, - storage_location: storage_location, - item: item, - item_quantity: inventory_item_quantity, - issued_at: Time.current - } + before :each do + TestInventory.create_inventory(organization, {storage_location.id => {item.id => current_quantity}}) + end - context "when minimum_quantity and recommended_quantity is nil" do - let(:item) { create :item, organization: organization } + context "when minimum_quantity and recommended_quantity are zero" do + let(:minimum_quantity) { 0 } + let(:recommended_quantity) { 0 } - it { is_expected.to eq [] } + it "should return an empty array" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to be_empty + end end context "when minimum_quantity is 0 and recommended_quantity is nil and item quantity is 0" do - let(:item) { create :item, organization: organization } let(:minimum_quantity) { 0 } + let(:current_quantity) { 0 } - # Use TestInventory to set up inventory directly, instead of creating - # a purchase with 0 quantity items (which our validation now rejects). - before do - TestInventory.create_inventory(organization, {storage_location.id => {item.id => 0}}) + it "should return an empty array" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to be_empty end - - it { is_expected.to eq [] } end context "when inventory quantity is over minimum quantity" do let(:minimum_quantity) { 50 } + let(:current_quantity) { 100 } - it { is_expected.to eq [] } + it "should return an empty array" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to be_empty + end end context "when minimum_quantity is equal to quantity" do let(:minimum_quantity) { 100 } + let(:current_quantity) { 100 } - it { is_expected.to eq [] } + it "should return an empty array" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to be_empty + end end context "when inventory quantity drops below minimum quantity" do let(:minimum_quantity) { 200 } + let(:current_quantity) { 100 } - it { - is_expected.to include({ + it "should include the item in the low inventory list" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to include({ id: item.id, name: item.name, on_hand_minimum_quantity: 200, on_hand_recommended_quantity: 0, total_quantity: 100 }) - } + end end context "when inventory quantity equals recommended quantity" do + let(:minimum_quantity) { 50 } let(:recommended_quantity) { 100 } + let(:current_quantity) { 100 } - it { is_expected.to eq [] } + it "should return an empty array" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to be_empty + end end context "when inventory quantity drops below recommended quantity" do + let(:minimum_quantity) { 50 } let(:recommended_quantity) { 200 } + let(:current_quantity) { 75 } - it { - is_expected.to include({ + it "should include the item in the low inventory list" do + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to include({ id: item.id, name: item.name, - on_hand_minimum_quantity: 0, + on_hand_minimum_quantity: 50, on_hand_recommended_quantity: 200, - total_quantity: 100 + total_quantity: 75 }) - } + end end context "when items are in multiple storage locations" do - let(:recommended_quantity) { 300 } + let(:minimum_quantity) { 50 } + let(:recommended_quantity) { 55 } + let(:current_quantity) { 40 } let(:secondary_storage_location) { create :storage_location, organization: organization } - let!(:secondary_purchase) { - create :purchase, - :with_items, - organization: organization, - storage_location: secondary_storage_location, - item: item, - item_quantity: inventory_item_quantity, - issued_at: Time.current - } - - it { - expect(subject.count).to eq 1 - } - - it { - is_expected.to include({ + + it "should have no low inventory items when global total is above minimum" do + TestInventory.create_inventory(organization, {secondary_storage_location.id => {item.id => 17}}) + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to be_empty + end + + it "should have no low inventory items when global total is below minimum" do + TestInventory.create_inventory(organization, {secondary_storage_location.id => {item.id => 2}}) + result = LowInventoryQuery.call(organization).map { |r| r.to_h.symbolize_keys } + expect(result).to include({ id: item.id, name: item.name, - on_hand_minimum_quantity: 0, - on_hand_recommended_quantity: 300, - total_quantity: 200 + on_hand_minimum_quantity: 50, + on_hand_recommended_quantity: 55, + total_quantity: 42 }) - } + end end end