151 lines
5.7 KiB
Python
151 lines
5.7 KiB
Python
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
|