from decimal import Decimal import pytest from ocado_grocy.grocy import Journal, import_receipt from ocado_grocy.receipt import ImportError, Item, Receipt class FakeGrocy: url = "https://grocy.test/api" def __init__(self, products=None, fail=False): self.products = products or [] self.posts = [] self.fail = fail def get(self, path): if path == "/objects/products": return self.products.copy() if path == "/objects/product_barcodes": return [] if path.startswith("/stock/products/"): return {"qu_conversion_factor_purchase_to_stock": 3} raise AssertionError(path) def named_object(self, *args, **kwargs): return 1 def post(self, path, payload): self.posts.append((path, payload)) if path == "/objects/products": self.products.append({"id": len(self.products) + 1, **payload}) return {"created_object_id": len(self.products)} if self.fail and path.endswith("/add"): raise ImportError("Network failed after stock may have been written") return [] def receipt(): return Receipt("123", "2026-09-08", [Item("Apples", Decimal(2), Decimal("4.50"), "ocado123")]) def test_import_creation_prices_and_duplicate_protection(tmp_path): api = FakeGrocy() journal = Journal(tmp_path / "state.sqlite3") assert import_receipt(receipt(), api, journal, {}) == 1 assert api.posts[0][0] == "/objects/products" stock = api.posts[-1][1] assert stock["amount"] == 2 assert stock["price"] == 2.25 assert stock["purchased_date"] == "2026-09-08" before = len(api.posts) assert import_receipt(receipt(), api, journal, {}) == 0 assert len(api.posts) == before def test_existing_product_conversion(tmp_path): api = FakeGrocy([{"id": 9, "name": "Apples", "qu_id_purchase": 1, "qu_id_stock": 2}]) import_receipt(receipt(), api, Journal(tmp_path / "state.sqlite3"), {}) assert len(api.posts) == 1 assert api.posts[0][0] == "/stock/products/9/add" assert api.posts[0][1]["amount"] == 6 assert api.posts[0][1]["price"] == .75 def test_uncertain_write_not_retried(tmp_path): api = FakeGrocy(fail=True) journal = Journal(tmp_path / "state.sqlite3") with pytest.raises(ImportError, match="Network failed"): import_receipt(receipt(), api, journal, {}) writes = len(api.posts) with pytest.raises(ImportError, match="uncertain"): import_receipt(receipt(), api, journal, {}) assert len(api.posts) == writes def test_changed_receipt_not_reimported(tmp_path): api = FakeGrocy() journal = Journal(tmp_path / "state.sqlite3") import_receipt(receipt(), api, journal, {}) changed = receipt() changed.items[0].quantity = Decimal(3) with pytest.raises(ImportError, match="changed"): import_receipt(changed, api, journal, {}) def test_preflight_rejects_ambiguity_before_writes(tmp_path): api = FakeGrocy([{"id": 1, "name": "Apples"}, {"id": 2, "name": "Apples"}]) with pytest.raises(ImportError, match="Ambiguous"): import_receipt(receipt(), api, Journal(tmp_path / "state.sqlite3"), {}) assert not api.posts def test_same_product_two_lines_created_once(tmp_path): api = FakeGrocy() order = receipt() order.items.append(order.items[0]) import_receipt(order, api, Journal(tmp_path / "state.sqlite3"), {}) assert len([p for p, _ in api.posts if p == "/objects/products"]) == 1 assert len([p for p, _ in api.posts if p.endswith("/add")]) == 2 @pytest.mark.parametrize('old_note', ['Ocado order 123, line 1.', 'Ocado 2026-09-08, line 1.']) def test_note_updates_never_recreate_deleted_stock(tmp_path, old_note): from copy import deepcopy from ocado_grocy.grocy import update_existing_notes class Api: url='https://test/api' def __init__(self): self.rows=[{'id':10,'product_id':1,'amount':2,'price':1.25,'open':0, 'best_before_date':'2027-01-01','purchased_date':'2026-09-08', 'location_id':1,'shopping_location_id':1,'note':old_note}] self.writes=[] def get(self,path): if path=='/stock/entry/10': return deepcopy(self.rows[0]) assert path=='/objects/stock' return deepcopy(self.rows) def request(self,method,path,data): assert method=='PUT' and path=='/stock/entry/10' self.writes.append(data) self.rows[0].update(data) journal=Journal(tmp_path/'state.sqlite3') api=Api() journal.record(api.url,'123',1,'existing','done',1) journal.record(api.url,'123',2,'deleted','done',2) # Missing from live stock. assert update_existing_notes(api,journal)==1 assert api.rows[0]['note']=='Ocado 2026-09-08' assert len(api.rows)==1 assert api.rows[0]['amount']==2 assert update_existing_notes(api,journal)==0 assert len(api.writes)==1 def test_note_update_skips_stock_deleted_after_listing(tmp_path): from ocado_grocy.grocy import update_existing_notes class Api: url='https://test/api' removed=False def get(self,path): if path=='/stock/entry/10': self.removed=True return None # Grocy returns JSON null for a deleted entry. assert path=='/objects/stock' return [] if self.removed else [{'id':10,'product_id':1,'note':'Ocado 2026-09-08, line 1.'}] def request(self,*args,**kwargs): pytest.fail('Must not write deleted stock') journal=Journal(tmp_path/'state.sqlite3') api=Api() journal.record(api.url,'123',1,'existing','done',1) assert update_existing_notes(api,journal)==0