Initial commit.

This commit is contained in:
Edward Betts 2026-09-19 11:32:53 +01:00
commit 628e5c3823
26 changed files with 3478 additions and 0 deletions

151
tests/test_grocy.py Normal file
View file

@ -0,0 +1,151 @@
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