ocado-grocy/tests/test_history.py

211 lines
9.5 KiB
Python

from dataclasses import replace
from decimal import Decimal
import json
from pathlib import Path
import pytest
from ocado_grocy.history import (order_links,private_json,cached_orders,select_orders,OrderLink,
discover_orders,save_order_manifest)
from ocado_grocy.pantry import classify
from ocado_grocy.receipt import ImportError,Item,parse_ocado_order
from ocado_grocy.grocy import (Journal,import_receipt,description,refresh_imported_products,
item_fingerprint,align_recorded_lines)
from test_grocy import FakeGrocy,receipt
FIXTURE=Path(__file__).parent/'fixtures'/'history_receipt.json'
class HistoryPage:
def __init__(self, batches, end_marker=True):
self.batches = batches
self.index = 0
self.end_marker = end_marker
self.mouse = self
def locator(self, selector):
page = self
class Locator:
def count(self):
if selector == 'a[href*="/orders/"]':
return len(page.batches[page.index])
return int(page.end_marker and page.index == len(page.batches) - 1)
@property
def last(self):
return self
def scroll_into_view_if_needed(self):
pass
return Locator()
def get_by_role(self, *args, **kwargs):
return self.locator('button')
def content(self):
return '<html>' + ''.join(
f'<a href="/orders/{order}/details">Sep 8, 2026 Delivered</a>'
for order in self.batches[self.index]) + '</html>'
def wheel(self, *args):
self.index = min(self.index + 1, len(self.batches) - 1)
def wait_for_timeout(self, milliseconds):
pass
@pytest.mark.parametrize('batches,completed,expected_index', [
([['3', '2'], ['3', '2', '1']], {'2', '1'}, 0),
([['3'], ['3', '2'], ['3', '2', '1']], {'2', '1'}, 1),
([['3'], ['3', '2'], ['3', '2', '1']], set(), 2),
])
def test_discovery_stops_at_completed_batch_or_end(batches, completed, expected_index):
page = HistoryPage(batches)
links = discover_orders(page, lambda message: None, completed=completed)
assert page.index == expected_index
assert [link.order_id for link in links] == batches[expected_index]
def test_explicit_discovery_searches_past_completed_orders():
page = HistoryPage([['3'], ['3', '2'], ['3', '2', '1']], end_marker=False)
links = discover_orders(page, lambda message: None, completed={'3'}, order_ids=('2',))
assert page.index == 1
assert [link.order_id for link in links] == ['3', '2']
def test_discovery_still_rejects_stalled_history():
with pytest.raises(ImportError, match='stopped loading'):
discover_orders(HistoryPage([['3']], end_marker=False), lambda message: None)
def test_incremental_manifest_preserves_older_pending_orders(tmp_path):
old = [OrderLink('2', '2026-09-07', 'old-url'), OrderLink('1', '2026-09-06', 'url')]
save_order_manifest(tmp_path, old)
new = [OrderLink('3', '2026-09-08', 'url'), replace(old[0], url='updated-url')]
merged = save_order_manifest(tmp_path, new)
assert merged == new + old[1:]
assert [link.order_id for link in select_orders(merged, completed={'2'})] == ['3', '1']
assert len(json.loads((tmp_path/'orders.json').read_text())) == 3
def test_storage_and_categories_select_without_a_product_list():
order=parse_ocado_order(json.loads(FIXTURE.read_text()))
assert sum(classify(i).include for i in order.items)==9
assert not any(classify(i).uncertain for i in order.items)
banana=next(i for i in order.items if 'Bananas' in i.name)
assert banana.metadata['storage_type']=='CUPBOARD'
assert not classify(banana).include
feta=next(i for i in order.items if 'Feta' in i.name)
assert feta.best_before.startswith('2030')
assert not classify(feta).include
def test_names_do_not_override_missing_metadata():
item=Item('Shelf-stable sounding Chocolate Biscuits',Decimal(1),Decimal(1),product_id='55')
assert classify(item).uncertain
assert not classify(item).include
assert classify(item,{'55':True}).include
with pytest.raises(ValueError):classify(item,{'55':'yes'})
def test_cold_storage_takes_precedence_over_category():
item=Item('Chocolate pudding',Decimal(1),Decimal(1),metadata={
'storage_type':'FRIDGE','ocado':{'categoryPath':['Treats & Snacks']}})
assert not classify(item).include
def test_order_links_ignore_cancelled_and_future_and_keep_year():
links=order_links('''<html>
<a href="/orders/1/details"><span>Sep 8, 2026 5:00pm</span><span>£56.75</span><span>Delivered</span></a>
<a href="/orders/2/details">Sep 8, 2025 5:00pm View order Delivered</a>
<a href="/orders/3/details">Sep 8, 2025 5:00pm You cancelled this order</a>
<a href="/orders/4/details">Fri 11 Sep You're editing this order</a></html>''')
assert [(r.order_id,r.purchased_date) for r in links]==[('1','2026-09-08'),('2','2025-09-08')]
def test_selection_does_not_change_complete_manifest(tmp_path):
links=[OrderLink('1','2026-09-08','https://www.ocado.com/orders/1/details'),
OrderLink('2','2025-09-08','https://www.ocado.com/orders/2/details')]
assert select_orders(links,limit=1)==links[:1]
assert select_orders(links,order_ids=('2',))==links[1:]
assert len(links)==2
with pytest.raises(ImportError,match='not found'):select_orders(links,order_ids=('missing',))
def test_partial_import_preserves_original_line_numbers(tmp_path):
order=receipt()
order.items.extend([Item('Milk',Decimal(1),Decimal(1)),Item('Tea',Decimal(1),Decimal(2))])
api=FakeGrocy()
journal=Journal(tmp_path/'journal.sqlite3')
assert import_receipt(order,api,journal,{},include_lines={3})==1
assert api.posts[-1][1]['note']=='Ocado 2026-09-08 Expiry unknown.'
assert journal.db.execute('SELECT line FROM imports').fetchone()[0]==3
assert import_receipt(order,api,journal,{},include_lines={3})==0
assert import_receipt(order,api,journal,{})==2
assert journal.db.execute('SELECT COUNT(*) FROM imports').fetchone()[0]==3
def test_cache_permissions_and_missing_order(tmp_path):
private_json(tmp_path/'orders.json',[{'order_id':'1','purchased_date':'2026-09-08','url':'https://www.ocado.com/orders/1/details'}])
assert (tmp_path/'orders.json').stat().st_mode & 0o777==0o600
orders,failures=cached_orders(tmp_path)
assert not orders and failures[0]['order_id']=='1'
@pytest.mark.parametrize("sanitized", [False, True])
def test_refresh_existing_includes_perishables_without_booking_stock(tmp_path, sanitized):
order=parse_ocado_order(json.loads(FIXTURE.read_text()))
index,item=next((i,item) for i,item in enumerate(order.items,1) if 'Lettuce' in item.name)
old=replace(item,metadata={'additional_details':{'brand':'Preserved'}, 'openfoodfacts':{'code':'00000048'}})
class Api:
url='https://test/api'
def __init__(self):
self.product={'id':1,'name':item.name,'description':description(old)}
if sanitized:
self.product['description'] = self.product['description'].replace('<pre>', '').replace('</pre>', '')
self.writes=[]
def get(self,path):
assert path=='/objects/products/1'
return self.product
def request(self,method,path,data):
assert method=='PUT' and path=='/objects/products/1'
self.writes.append(data)
self.product.update(data)
api=Api()
journal=Journal(tmp_path/'state.sqlite3')
journal.record(api.url,order.order_id,index,item_fingerprint(order,item),'done',1)
assert not classify(item).include
assert refresh_imported_products(order,api,journal)==1
assert 'FRIDGE' in api.product['description']
assert 'Preserved' in api.product['description']
assert '00000048' in api.product['description']
assert refresh_imported_products(order,api,journal)==0
assert len(api.writes)==1
def test_reordered_receipt_recovers_original_journal_lines(tmp_path):
original=receipt()
original.items.append(Item('Tea',Decimal(1),Decimal(2)))
api=FakeGrocy()
journal=Journal(tmp_path/'state.sqlite3')
import_receipt(original,api,journal,{})
reordered=replace(original,items=list(reversed(original.items)))
restored=align_recorded_lines(reordered,api.url,journal)
assert restored.items==original.items
assert import_receipt(restored,api,journal,{})==0
@pytest.mark.parametrize("flags,expected", [([],21), (["--all-products"],21), (["--shelf-stable-only"],9)])
def test_cached_cli_preview_does_not_write_stock_or_use_browser(tmp_path,monkeypatch,flags,expected):
from click.testing import CliRunner
from ocado_grocy.cli import main
order=parse_ocado_order(json.loads(FIXTURE.read_text()))
private_json(tmp_path/'orders.json',[{'order_id':order.order_id,'purchased_date':order.purchased_date,
'url':f'https://www.ocado.com/orders/{order.order_id}/details'}])
private_json(tmp_path/f'{order.order_id}.json',order.export())
monkeypatch.setattr('requests.Session.request',lambda *a,**kw:pytest.fail('Unexpected Grocy request'))
monkeypatch.setattr('ocado_grocy.history.download_orders',lambda *a,**kw:pytest.fail('Unexpected browser'))
result=CliRunner().invoke(main,flags+['--cached','--dry-run','--archive',str(tmp_path),
'--config',str(tmp_path/'missing-config.toml')])
assert result.exit_code==0,result.output
report=json.loads((tmp_path/'report.json').read_text())
assert sum(i['include'] for i in report['items'])==expected
assert report['imported_lines']==0