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

870
tests/fixtures/history_receipt.json vendored Normal file
View file

@ -0,0 +1,870 @@
{
"_comment": "Synthetic API fixture. All identifiers, product details, dates and prices are invented.",
"result": "1000000000001",
"entities": {
"order": {
"1000000000001": {
"orderId": "1000000000001",
"status": "DELIVERED",
"dates": {
"deliveryStartDate": "2020-01-02T12:00:00Z",
"timeZoneId": "Europe/London"
},
"orderTotals": {
"itemPriceAfterPromos": {
"amount": "28.75",
"currency": "GBP"
}
},
"totalItems": 23,
"groupedProducts": {
"products": [
{
"productId": "example-product-1",
"retailerProductId": "90000001",
"name": "Example Bananas",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-1.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-2",
"retailerProductId": "90000002",
"name": "Example Lettuce",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-2.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-3",
"retailerProductId": "90000003",
"name": "Example Feta",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2030-01-01",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-3.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-4",
"retailerProductId": "90000004",
"name": "Example Milk",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-4.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-5",
"retailerProductId": "90000005",
"name": "Example Yogurt",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-5.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-6",
"retailerProductId": "90000006",
"name": "Example Frozen Peas",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FREEZER",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-6.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-7",
"retailerProductId": "90000007",
"name": "Example Bread",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-7.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-8",
"retailerProductId": "90000008",
"name": "Example Apples",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-8.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-9",
"retailerProductId": "90000009",
"name": "Example Carrots",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-9.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-10",
"retailerProductId": "90000010",
"name": "Example Spinach",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-10.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-11",
"retailerProductId": "90000011",
"name": "Example Frozen Berries",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FREEZER",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-11.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-12",
"retailerProductId": "90000012",
"name": "Example Fresh Pasta",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "FRIDGE",
"expirationDate": "2020-01-05",
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-12.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-13",
"retailerProductId": "90000013",
"name": "Example Rice",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-13.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-14",
"retailerProductId": "90000014",
"name": "Example Dried Pasta",
"quantity": 2,
"prices": {
"retail": {
"amount": "3.00",
"currency": "GBP"
},
"offered": {
"amount": "2.50",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-14.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-15",
"retailerProductId": "90000015",
"name": "Example Tea",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-15.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-16",
"retailerProductId": "90000016",
"name": "Example Coffee",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-16.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-17",
"retailerProductId": "90000017",
"name": "Example Biscuits",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-17.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-18",
"retailerProductId": "90000018",
"name": "Example Tinned Tomatoes",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-18.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-19",
"retailerProductId": "90000019",
"name": "Example Soap",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-19.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-20",
"retailerProductId": "90000020",
"name": "Example Cleaning Cloths",
"quantity": 2,
"prices": {
"retail": {
"amount": "3.00",
"currency": "GBP"
},
"offered": {
"amount": "2.50",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-20.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
},
{
"productId": "example-product-21",
"retailerProductId": "90000021",
"name": "Example Pet Food",
"quantity": 1,
"prices": {
"retail": {
"amount": "1.50",
"currency": "GBP"
},
"offered": {
"amount": "1.25",
"currency": "GBP"
},
"pricePerItem": {
"amount": "1.25",
"currency": "GBP"
}
},
"storageType": "CUPBOARD",
"expirationDate": null,
"isInCurrentCatalog": true,
"image": {
"src": "https://www.ocado.com/images-v3/example-21.jpg"
},
"packInfo": {
"packSizeDescription": "250g"
},
"promotions": []
}
],
"substitutes": []
}
}
},
"product": {
"example-product-1": {
"retailerProductId": "90000001",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-2": {
"retailerProductId": "90000002",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-3": {
"retailerProductId": "90000003",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-4": {
"retailerProductId": "90000004",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-5": {
"retailerProductId": "90000005",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-6": {
"retailerProductId": "90000006",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-7": {
"retailerProductId": "90000007",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-8": {
"retailerProductId": "90000008",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-9": {
"retailerProductId": "90000009",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-10": {
"retailerProductId": "90000010",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-11": {
"retailerProductId": "90000011",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-12": {
"retailerProductId": "90000012",
"brand": "Example Brand",
"categoryPath": [
"Fresh & Chilled Food"
],
"size": {
"value": "250g"
}
},
"example-product-13": {
"retailerProductId": "90000013",
"brand": "Example Brand",
"categoryPath": [
"Food Cupboard"
],
"size": {
"value": "250g"
}
},
"example-product-14": {
"retailerProductId": "90000014",
"brand": "Example Brand",
"categoryPath": [
"Food Cupboard"
],
"size": {
"value": "250g"
}
},
"example-product-15": {
"retailerProductId": "90000015",
"brand": "Example Brand",
"categoryPath": [
"Food Cupboard"
],
"size": {
"value": "250g"
}
},
"example-product-16": {
"retailerProductId": "90000016",
"brand": "Example Brand",
"categoryPath": [
"Food Cupboard"
],
"size": {
"value": "250g"
}
},
"example-product-17": {
"retailerProductId": "90000017",
"brand": "Example Brand",
"categoryPath": [
"Food Cupboard"
],
"size": {
"value": "250g"
}
},
"example-product-18": {
"retailerProductId": "90000018",
"brand": "Example Brand",
"categoryPath": [
"Food Cupboard"
],
"size": {
"value": "250g"
}
},
"example-product-19": {
"retailerProductId": "90000019",
"brand": "Example Brand",
"categoryPath": [
"Household & Cleaning"
],
"size": {
"value": "250g"
}
},
"example-product-20": {
"retailerProductId": "90000020",
"brand": "Example Brand",
"categoryPath": [
"Household & Cleaning"
],
"size": {
"value": "250g"
}
},
"example-product-21": {
"retailerProductId": "90000021",
"brand": "Example Brand",
"categoryPath": [
"Pets"
],
"size": {
"value": "250g"
}
}
}
}
}

View file

@ -0,0 +1,85 @@
import sqlite3
from types import SimpleNamespace
from click.testing import CliRunner
from ocado_grocy.enrichment_retry import RetryQueue
from ocado_grocy.history import private_json, main
from ocado_grocy import postprocess
from ocado_grocy.order_state import mark_orders
def test_legacy_report_recovery_is_scoped_and_does_not_resurrect_old_errors(tmp_path):
output = tmp_path/'enrichment'/'old'
private_json(output/'summary.json',{'order_ids':['123'],'errors':['Open Food Facts: failed']})
private_json(output/'openfoodfacts.json',{'products':[
{'product_id':1,'status':'error','error':'ConnectionError'},
{'product_id':2,'status':'unchanged'}, {'product_id':99,'status':'error'}]})
queue = RetryQueue(tmp_path/'state.sqlite3','server')
queue.migrate_reports(tmp_path,{1,2})
assert queue.pending('openfoodfacts') == {1}
queue.results('openfoodfacts',[{'product_id':1,'status':'unchanged'}])
queue.migrate_reports(tmp_path,{1,2})
assert not queue.pending('openfoodfacts')
queue.close()
def test_queue_separates_stages_and_servers_and_keeps_only_failures(tmp_path):
queue = RetryQueue(tmp_path/'state.sqlite3','server')
queue.start('images',[{'id':1},{'id':2}])
queue.start('openfoodfacts',[{'id':1}])
queue.results('images',[{'product_id':1,'status':'updated'},{'product_id':2,'status':'error'}])
assert queue.pending('images') == {2}
assert queue.pending('openfoodfacts') == {1}
other = RetryQueue(tmp_path/'state.sqlite3','other')
assert not other.pending('images')
other.close()
queue.close()
def test_retry_targets_only_failed_live_imports_and_clears_success(tmp_path, monkeypatch):
state = tmp_path/'imports.sqlite3'
with sqlite3.connect(state) as db:
db.execute('CREATE TABLE imports(server,product_id,status)')
db.executemany('INSERT INTO imports VALUES (?,?,?)',[('server',1,'done'),('server',2,'done'),('server',3,'done')])
queue = RetryQueue(state,'server')
queue.start('openfoodfacts',[{'id':1},{'id':3}])
queue.close()
class Client:
url = 'server'
def get(self,path):
assert path == '/objects/products'
return [{'id':1},{'id':2}] # 3 was deleted
monkeypatch.setattr(postprocess,'Grocy',lambda *a:Client())
calls = []
def run(client, products, cache, contact):
calls.append(products)
return {'counts':{'unchanged':1},'products':[{'product_id':1,'status':'unchanged'}]}
monkeypatch.setattr(postprocess,'run_openfoodfacts',run)
result = postprocess.retry_enrichment({'openfoodfacts':{'contact':'test@example.com'}},tmp_path/'config.toml',tmp_path)
assert calls == [[{'id':1}]] and not result['errors']
queue = RetryQueue(state,'server')
assert not queue.pending('openfoodfacts')
queue.close()
def test_main_retries_even_with_no_new_orders(tmp_path, monkeypatch):
from ocado_grocy import history
monkeypatch.setattr(history,'cached_orders',lambda *a:([],[]))
calls = []
monkeypatch.setattr(postprocess,'retry_enrichment',lambda *a,**kw:calls.append(True) or {'errors':[]})
result = CliRunner().invoke(main,['--cached','--config',str(tmp_path/'config.toml'),'--archive',str(tmp_path)])
assert result.exit_code == 0 and calls == [True]
assert 'No new delivered orders' in result.output
calls.clear()
result = CliRunner().invoke(main,['--cached','--dry-run','--config',str(tmp_path/'config.toml'),'--archive',str(tmp_path)])
assert result.exit_code == 0 and not calls
def test_retry_failure_keeps_error_exit_even_without_new_orders(tmp_path, monkeypatch):
from ocado_grocy import history
monkeypatch.setattr(history,'cached_orders',lambda *a:([],[]))
monkeypatch.setattr(postprocess,'retry_enrichment',lambda *a,**kw:{'errors':['ConnectionError']})
result = CliRunner().invoke(main,['--cached','--config',str(tmp_path/'config.toml'),'--archive',str(tmp_path)])
assert result.exit_code == 1
assert 'Enrichment retries still need attention' in result.output

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

140
tests/test_history.py Normal file
View file

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

87
tests/test_images.py Normal file
View file

@ -0,0 +1,87 @@
import json
from types import SimpleNamespace
import pytest
from ocado_grocy.images import add_picture, file_path, image_type
DATA = b'\xff\xd8\xfftest-image'
def product():
return {'id':72, 'name':'Ocado title', 'description':'<p>Ocado product ID: 123</p>' + json.dumps({'Ocado product ID':'123','image_url':'https://www.ocado.com/images-v3/test.jpg'}), 'picture_file_name':None}
def response(data=DATA):
return SimpleNamespace(status_code=200, content=data, raise_for_status=lambda:None)
class API:
url = 'https://grocy.test/api'
timeout = 30
def __init__(self):
self.product = product()
self.writes = []
self.stored = DATA
self.session = self
def get(self, path, **kwargs):
if path.startswith(self.url):
assert '/files/productpictures/' in path
return response(self.stored)
assert path == '/objects/products/72'
return self.product.copy()
def put(self, path, **kwargs):
assert path.startswith(self.url + '/files/productpictures/')
assert kwargs['data'] == DATA
assert kwargs['headers'] == {'Content-Type':'application/octet-stream'}
self.writes.append(path)
return response()
def request(self, method, path, payload):
assert method == 'PUT' and path == '/objects/products/72'
assert set(payload) == {'picture_file_name'}
self.writes.append(payload)
self.product.update(payload)
class Downloader:
def get(self, url, **kwargs):
assert url == 'https://www.ocado.com/images-v3/test.jpg'
assert set(kwargs) == {'timeout','allow_redirects'}
return response()
def test_upload_verifies_file_and_only_sets_picture_field():
api = API()
assert add_picture(api, Downloader(), product()) == 'updated'
assert len(api.writes) == 2
assert api.product['name'] == 'Ocado title'
assert api.product['description'] == product()['description']
assert add_picture(api, Downloader(), api.product) == 'existing_picture'
assert len(api.writes) == 2
def test_failed_file_verification_does_not_assign_picture():
api = API()
api.stored = b'wrong file'
with pytest.raises(ValueError, match='verification'):
add_picture(api, Downloader(), product())
assert api.product['picture_file_name'] is None
def test_concurrent_picture_or_deleted_product_is_preserved():
api = API()
api.product['picture_file_name'] = 'user-picture.jpg'
assert add_picture(api, Downloader(), product()) == 'existing_picture'
api.product = {}
assert add_picture(api, Downloader(), product()) == 'deleted_product'
assert not api.writes
def test_rejects_non_images_and_external_urls():
with pytest.raises(ValueError):
image_type(b'<html>not an image</html>')
p = product()
p['description'] = p['description'].replace('www.ocado.com', 'example.com')
with pytest.raises(ValueError, match='Ocado image URL'):
add_picture(API(), Downloader(), p)
assert file_path('test.jpg') == '/files/productpictures/dGVzdC5qcGc%3D'

139
tests/test_openfoodfacts.py Normal file
View file

@ -0,0 +1,139 @@
import json
import pytest
from ocado_grocy.openfoodfacts import candidate_info, pack, valid_barcode, apply_match
from ocado_grocy.product_metadata import read_metadata, replace_metadata
def metadata():
return {'Ocado product ID':'123', 'Barcode':'', 'ocado':{'brand':'M&S', 'size':{'value':'250g'}}, 'custom':'keep me'}
def product():
return {'id':42, 'name':'M&S Ginger Snaps', 'qu_id_purchase':1, 'description':'<p>Ocado product ID: 123</p>' + json.dumps(metadata()) + '<p>User comment</p>'}
def candidate():
return {'code':'00000048', 'product_name':'Ginger Snaps', 'brands':'Marks & Spencer', 'quantity':'250 g', 'ingredients_text':'Ginger & flour', 'nutriments':{'energy-kcal_100g':450}}
def test_matching_rejects_variant_brand_and_pack_changes():
assert candidate_info(product(), metadata(), candidate())['exact']
for change in ({'quantity':'300 g'}, {'quantity':'2 x 125g'}, {'brands':'Tesco'}, {'product_name':'Reduced Sugar Ginger Snaps'}, {'code':'00000041'}):
assert not candidate_info(product(), metadata(), {**candidate(), **change})['exact']
assert pack('1.25L') == pack('1250ml')
assert pack('250g') != pack('250ml')
assert pack('5 per pack') is None
assert not valid_barcode('123456011')
def test_metadata_survives_sanitizer_and_retains_user_comments():
p = product()
m = read_metadata(p['description'])
m['openfoodfacts'] = {'ingredients':'Ginger & flour < 1%'}
updated = replace_metadata(p['description'], m)
assert read_metadata(updated) == m
assert '<p>User comment</p>' in updated
assert 'Ginger &amp; flour &lt; 1%' in updated
class API:
def __init__(self):
self.p = product()
self.writes = []
def get(self, path):
assert path == '/objects/products/42'
return self.p.copy()
def post(self, path, data):
assert path == '/objects/product_barcodes'
self.writes.append((path, data))
def request(self, method, path, data):
assert method == 'PUT' and path == '/objects/products/42'
assert set(data) == {'description'}
self.writes.append((path, data))
self.p.update(data)
def test_enrichment_is_idempotent_and_never_changes_stock_or_title():
api = API()
barcodes = []
assert apply_match(api, product(), candidate(), barcodes) == 'updated'
assert apply_match(api, product(), candidate(), barcodes) == 'unchanged'
assert len(api.writes) == 2
assert api.p['name'] == product()['name']
assert read_metadata(api.p['description'])['custom'] == 'keep me'
assert read_metadata(api.p['description'])['openfoodfacts']['nutriments']['energy-kcal_100g'] == 450
def test_conflicting_barcode_and_deleted_product_do_not_write():
api = API()
with pytest.raises(ValueError, match='another Grocy product'):
apply_match(api, product(), candidate(), [{'barcode':'00000048','product_id':99}])
api.p = {}
with pytest.raises(ValueError, match='deleted'):
apply_match(api, product(), candidate(), [])
assert not api.writes
def test_two_exact_barcodes_are_ambiguous():
from ocado_grocy.openfoodfacts import choose_match
first = candidate()
second = {**first, 'code':'00000055'}
ranked, match = choose_match(product(), metadata(), {first['code']:first, second['code']:second})
assert len(ranked) == 2
assert match is None
def test_truncated_catalog_is_split_and_pages_must_be_complete(tmp_path):
from ocado_grocy.openfoodfacts import OpenFoodFacts
class OFF(OpenFoodFacts):
def fetch(self, path, params, **kwargs):
if params['q'] == 'brands_tags:(a OR b)':
return {'is_count_exact':False, 'hits':[]}
code = '00000048' if params['q'] == 'brands_tags:(a)' else '00000055'
return {'is_count_exact':True, 'count':1, 'page_count':1, 'hits':[{'code':code,'brands':['a']}]}
off = OFF(tmp_path, 'test@example.com')
assert len(off.catalog({'a','b'})) == 2
off.fetch = lambda *args, **kwargs: {'is_count_exact':True, 'count':2, 'page_count':1, 'hits':[]}
with pytest.raises(ValueError, match='Incomplete'):
off.catalog({'a'})
def test_ms_food_brand_and_missing_barcode_recovery():
assert candidate_info(product(), metadata(), {**candidate(), 'brands':'M&S Food'})['exact']
api = API()
barcodes = []
apply_match(api, product(), candidate(), barcodes)
# Simulate an interrupted attempt with metadata present but no barcode row.
assert apply_match(api, api.p.copy(), candidate(), []) == 'updated'
assert len([p for p, data in api.writes if p == '/objects/products/42']) == 1
def test_established_barcode_match_survives_a_missing_catalog_entry(tmp_path, monkeypatch):
from ocado_grocy import openfoodfacts as module
p = product()
m = metadata()
m.update(Barcode=candidate()['code'], openfoodfacts=candidate())
p['description'] = replace_metadata(p['description'], m)
class OFF:
def __init__(self, *a): pass
def catalog(self, *a): return []
def product(self, code, offline):
assert code == candidate()['code']
return candidate()
class Client:
def get(self, path):
assert path == '/objects/product_barcodes'
return []
monkeypatch.setattr(module, 'OpenFoodFacts', OFF)
result = module.run_openfoodfacts(Client(), [p], tmp_path, 'test@example.com', apply=False)
assert result['products'][0]['match_method'] == 'existing_match'
assert result['products'][0]['status'] == 'matched'
def test_review_ranks_the_right_name_ahead_of_unrelated_matching_pack():
from ocado_grocy.openfoodfacts import choose_match
missing_size = {**candidate(), 'quantity':''}
unrelated = {**candidate(), 'code':'00000055', 'product_name':'Tomato Ketchup'}
ranked, matched = choose_match(product(), metadata(), {c['code']:c for c in [missing_size, unrelated]})
assert ranked[0]['code'] == missing_size['code']
assert matched is None

58
tests/test_order_state.py Normal file
View file

@ -0,0 +1,58 @@
from dataclasses import replace
from click.testing import CliRunner
from ocado_grocy.grocy import Journal, item_fingerprint
from ocado_grocy.history import OrderLink, main, private_json, select_orders
from ocado_grocy.order_state import completed_orders, mark_orders
from test_grocy import receipt
def test_completed_filter_precedes_limit_and_explicit_order_bypasses_it():
links = [OrderLink(str(i),f'2026-09-{i:02d}','url') for i in (1,2,3)]
assert [r.order_id for r in select_orders(links,limit=1,completed={'3'})] == ['2']
assert [r.order_id for r in select_orders(links,order_ids=('3',),completed={'3'})] == ['3']
def test_legacy_migration_requires_all_selected_lines_and_never_just_a_cache(tmp_path):
state = tmp_path/'imports.sqlite3'
order = receipt()
item = replace(order.items[0], metadata={'ocado':{'categoryPath':['Food Cupboard']}})
order = replace(order,items=[item,replace(item,name='Second product')])
private_json(tmp_path/'123.json',order.export())
journal = Journal(state)
assert completed_orders(state,'server',tmp_path,{}) == set()
journal.record('server','123',1,item_fingerprint(order,item),'done',1)
assert completed_orders(state,'server',tmp_path,{}) == set()
journal.record('server','123',2,item_fingerprint(order,order.items[1]),'pending',2)
assert completed_orders(state,'server',tmp_path,{}) == set()
journal.record('server','123',2,item_fingerprint(order,order.items[1]),'done',2)
assert completed_orders(state,'server',tmp_path,{}) == {'123'}
assert completed_orders(state,'other-server',tmp_path,{}) == set()
# Enrichment failure must override legacy done stock lines.
mark_orders(state,'server',['123'],'processing')
assert completed_orders(state,'server',tmp_path,{}) == set()
mark_orders(state,'server',['123'],'complete')
assert completed_orders(state,'server',tmp_path,{}) == {'123'}
journal.db.close()
def test_empty_selection_completion_and_read_only_missing_state(tmp_path):
state = tmp_path/'imports.sqlite3'
assert completed_orders(state,'server',tmp_path,{}) == set()
assert not state.exists()
mark_orders(state,'server',['123'],'complete')
assert completed_orders(state,'server',tmp_path,{}) == {'123'}
def test_default_cached_run_skips_receipt_loading_and_all_processing(tmp_path, monkeypatch):
from ocado_grocy import history
private_json(tmp_path/'orders.json',[{'order_id':'123','purchased_date':'2026-09-08','url':'url'}])
# No receipt file: skipping must happen before trying to load it.
mark_orders(tmp_path/'imports.sqlite3','/api',['123'],'complete')
def unexpected(*args, **kwargs):
raise AssertionError('Completed order was processed again')
monkeypatch.setattr(history,'run_import',unexpected)
result = CliRunner().invoke(main,['--cached','--archive',str(tmp_path),'--config',str(tmp_path/'config.toml')])
assert result.exit_code == 0, result.output
assert 'No new delivered orders' in result.output

84
tests/test_postprocess.py Normal file
View file

@ -0,0 +1,84 @@
import sqlite3
from pathlib import Path
from click.testing import CliRunner
import pytest
from ocado_grocy.grocy import imported_products
from ocado_grocy.history import main
from ocado_grocy import postprocess
def test_scope_uses_completed_lines_for_selected_order_and_server(tmp_path):
state = tmp_path / 'journal.sqlite3'
with sqlite3.connect(state) as db:
db.execute('CREATE TABLE imports(server,order_id,status,product_id)')
db.executemany('INSERT INTO imports VALUES (?,?,?,?)', [
('server','latest','done',1), ('server','older','done',2),
('server','latest','pending',3), ('other','latest','done',4),
('server','latest','done',5)])
class Client:
url = 'server'
def get(self, path):
assert path == '/objects/products'
return [{'id':i} for i in range(1,5)] # 5 was deleted
assert imported_products(Client(), state, ['latest']) == [{'id':1}]
@pytest.mark.parametrize('dry_run', [False, True])
def test_main_runs_enrichment_even_when_no_stock_added_but_never_in_dry_run(tmp_path, monkeypatch, dry_run):
from ocado_grocy import history
from test_grocy import receipt
monkeypatch.setattr(history, 'cached_orders', lambda *a: ([receipt()], []))
monkeypatch.setattr(history, 'run_import', lambda *a, **kw: {'items':[], 'imported_lines':0})
calls = []
monkeypatch.setattr(postprocess, 'enrich_orders', lambda *a, **kw: calls.append((a,kw)) or {'errors':[]})
args = ['--cached','--archive',str(tmp_path),'--config',str(tmp_path/'config.toml')]
if dry_run:
args.append('--dry-run')
result = CliRunner().invoke(main, args)
assert result.exit_code == 0, result.output
assert len(calls) == (0 if dry_run else 1)
if calls:
assert calls[0][0][3] == ['123']
assert calls[0][1] == {'images':True,'openfoodfacts':True}
def test_picture_failure_does_not_prevent_off_and_errors_are_recorded(tmp_path, monkeypatch):
from types import SimpleNamespace
client = SimpleNamespace(url='server')
products = [{'id':42}]
monkeypatch.setattr(postprocess, 'Grocy', lambda *a: client)
monkeypatch.setattr(postprocess, 'imported_products', lambda *a: products)
def fail_images(*args, **kwargs):
raise ValueError('image unavailable')
monkeypatch.setattr(postprocess, 'run_images', fail_images)
calls = []
def off(api, selected, cache, contact):
assert api is client and selected == products
calls.append(True)
return {'counts':{'unchanged':1}, 'products':[]}
monkeypatch.setattr(postprocess, 'run_openfoodfacts', off)
result = postprocess.enrich_orders({'openfoodfacts':{'contact':'test@example.com'}}, tmp_path/'config.toml',tmp_path,['123'])
assert calls and result['openfoodfacts'] == {'unchanged':1}
assert result['errors'] == ['Images: image unavailable']
assert (tmp_path/'enrichment/123/summary.json').exists()
def test_disabled_enrichment_does_not_access_grocy(tmp_path, monkeypatch):
monkeypatch.setattr(postprocess, 'Grocy', lambda *a: (_ for _ in ()).throw(AssertionError('Unexpected API access')))
assert postprocess.enrich_orders({}, tmp_path/'config.toml',tmp_path,['123'],images=False,openfoodfacts=False) == {'errors':[]}
def test_enrichment_failure_reports_stock_completion_and_nonzero_exit(tmp_path, monkeypatch):
from ocado_grocy import history
from test_grocy import receipt
monkeypatch.setattr(history, 'cached_orders', lambda *a: ([receipt()], []))
monkeypatch.setattr(history, 'run_import', lambda *a, **kw: {'items':[], 'imported_lines':1})
monkeypatch.setattr(postprocess, 'enrich_orders', lambda *a, **kw: {'errors':['OFF unavailable']})
result = CliRunner().invoke(main, ['--cached','--archive',str(tmp_path),'--config',str(tmp_path/'config.toml')])
assert result.exit_code == 1
assert 'Stock import completed; enrichment needs a retry' in result.output
import json
saved = json.loads((tmp_path/'report.json').read_text())
assert saved['imported_lines'] == 1 and saved['enrichment']['errors'] == ['OFF unavailable']

118
tests/test_receipt.py Normal file
View file

@ -0,0 +1,118 @@
from copy import deepcopy
from decimal import Decimal
import json
from pathlib import Path
import pytest
from ocado_grocy.receipt import ImportError, number, parse_document, parse_ocado_order
FIXTURE = Path(__file__).parent / 'fixtures' / 'history_receipt.json'
def api_data():
return json.loads(FIXTURE.read_text())
def test_synthetic_api_receipt():
receipt = parse_ocado_order(api_data())
assert receipt.order_id == '1000000000001'
assert receipt.purchased_date == '2020-01-02'
assert len(receipt.items) == 21
assert sum(i.quantity for i in receipt.items) == 23
assert sum(i.total for i in receipt.items) == Decimal('28.75')
lettuce = next(i for i in receipt.items if 'Lettuce' in i.name)
assert lettuce.best_before == '2020-01-05'
assert lettuce.metadata['storage_type'] == 'FRIDGE'
cloths = next(i for i in receipt.items if 'Cleaning Cloths' in i.name)
assert cloths.quantity == 2
assert cloths.unit_price == Decimal('1.25')
assert cloths.best_before is None
def test_order_identity_and_delivery_status():
with pytest.raises(ImportError,match='does not match'):
parse_ocado_order(api_data(),expected_order_id='123')
data=api_data()
data['entities']['order'][data['result']]['status']='CONFIRMED'
with pytest.raises(ImportError,match='not delivered'):
parse_ocado_order(data)
def test_summary_detects_partial_receipt():
data=api_data()
data['entities']['order'][data['result']]['groupedProducts']['products'].pop()
with pytest.raises(ImportError,match='quantities'):
parse_ocado_order(data)
def test_summary_detects_wrong_price():
data=api_data()
data['entities']['order'][data['result']]['groupedProducts']['products'][0]['prices']['offered']['amount']='9.99'
with pytest.raises(ImportError,match='prices'):
parse_ocado_order(data)
def test_only_accepted_substitutes_are_imported():
data=api_data()
order=data['entities']['order'][data['result']]
original=order['groupedProducts']['products'].pop(0)
accepted={**deepcopy(original),'productId':'replacement','retailerProductId':'999','name':'Replacement bananas','status':'ACCEPTED'}
rejected={**deepcopy(original),'productId':'rejected','retailerProductId':'888','name':'Rejected bananas','status':'REJECTED'}
original['substitutes']=[accepted,rejected]
order['groupedProducts']['substitutes']=[original]
receipt=parse_ocado_order(data)
assert len(receipt.items)==21
assert any(i.product_id=='999' for i in receipt.items)
assert not any(i.product_id in {'888',original['retailerProductId']} for i in receipt.items)
assert next(i for i in receipt.items if i.product_id=='999').metadata['substituted_for']==original['name']
def test_input_order_does_not_change_stable_line_order():
first=api_data()
second=deepcopy(first)
second['entities']['order'][second['result']]['groupedProducts']['products'].reverse()
assert parse_ocado_order(first)==parse_ocado_order(second)
def test_discontinued_product_does_not_need_html_link():
data=api_data()
product=data['entities']['order'][data['result']]['groupedProducts']['products'][0]
product['isInCurrentCatalog']=False
receipt=parse_ocado_order(data)
assert any(i.product_id==product['retailerProductId'] for i in receipt.items)
def test_purchase_date_is_local_delivery_date():
data=api_data()
data['entities']['order'][data['result']]['dates']['deliveryStartDate']='2026-09-01T23:30:00Z'
assert parse_ocado_order(data).purchased_date=='2026-09-02'
@pytest.mark.parametrize('value',['NaN','Infinity','-1','nonsense',None])
def test_invalid_numbers(value):
with pytest.raises(ImportError):number(value,'quantity')
def test_free_and_weighted_items():
receipt=parse_document({'order_id':'1','items':[
{'name':'Free gift','quantity':1,'total':0},
{'name':'Apples','quantity':'0.75','total':'1.50'}]})
assert receipt.items[0].unit_price==0
assert receipt.items[1].unit_price==2
def test_receipt_json_roundtrip():
receipt=parse_ocado_order(api_data())
assert parse_document(json.loads(json.dumps(receipt.export(),default=str)))==receipt
def test_live_basket_cannot_be_mistaken_for_order():
with pytest.raises(ImportError,match='structure'):
parse_ocado_order({'data':{'basket':{'items':[{'name':'Not an order'}]}}})
def test_non_gbp_order_rejected():
data=api_data()
data['entities']['order'][data['result']]['groupedProducts']['products'][0]['prices']['offered']['currency']='EUR'
with pytest.raises(ImportError,match='currency'):parse_ocado_order(data)