forked from cartsnitch/cartsnitch
Squashed 'receiptwitness/' content from commit e8d374a
git-subtree-dir: receiptwitness git-subtree-split: e8d374a89ed8978f429598e02d31b1c5963efe22
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
"""Regression tests: graceful handling of page layout changes.
|
||||
|
||||
Retailers frequently change their API response structures, field names,
|
||||
and nesting. These tests verify that both parsers degrade gracefully when
|
||||
encountering alternative or missing fields — producing valid output
|
||||
instead of crashing.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from receiptwitness.parsers.kroger import parse_kroger_receipt
|
||||
from receiptwitness.parsers.meijer import parse_meijer_receipt
|
||||
from receiptwitness.scrapers.base import RawReceipt
|
||||
|
||||
|
||||
class TestKrogerFieldNameVariations:
|
||||
"""Kroger changes field names between app versions and API revisions."""
|
||||
|
||||
def test_alternative_item_key_line_items(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-ALT-1",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"lineItems": [{"description": "MILK", "basePrice": 3.99, "totalPrice": 3.99}],
|
||||
"total": 3.99,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["product_name_raw"] == "MILK"
|
||||
|
||||
def test_alternative_item_key_receipt_items(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-ALT-2",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"receiptItems": [
|
||||
{"description": "EGGS", "basePrice": 5.49, "totalPrice": 5.49}
|
||||
],
|
||||
"total": 5.49,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["product_name_raw"] == "EGGS"
|
||||
|
||||
def test_alternative_description_fields(self):
|
||||
"""Test productName and itemDescription fallbacks."""
|
||||
for field in ("productName", "itemDescription", "name"):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-DESC",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{field: "TEST PRODUCT", "basePrice": 1.00, "totalPrice": 1.00}],
|
||||
"total": 1.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["items"][0]["product_name_raw"] == "TEST PRODUCT"
|
||||
|
||||
def test_alternative_price_fields(self):
|
||||
"""Test unitPrice and price fallbacks for basePrice."""
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-PRICE-1",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"description": "ITEM A", "unitPrice": 2.50, "totalPrice": 2.50}],
|
||||
"total": 2.50,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["items"][0]["unit_price"] == Decimal("2.50")
|
||||
|
||||
raw2 = RawReceipt(
|
||||
receipt_id="KR-PRICE-2",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"description": "ITEM B", "price": 4.00, "totalPrice": 4.00}],
|
||||
"total": 4.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result2 = parse_kroger_receipt(raw2)
|
||||
assert result2["items"][0]["unit_price"] == Decimal("4.00")
|
||||
|
||||
def test_alternative_total_fields(self):
|
||||
"""Test orderTotal, grandTotal fallbacks."""
|
||||
for field in ("orderTotal", "grandTotal"):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-TOT",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={field: 42.50, "detail": {}},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["total"] == Decimal("42.50")
|
||||
|
||||
def test_alternative_savings_fields(self):
|
||||
"""Test youSaved and totalDiscount fallbacks."""
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-SAV-1",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={"youSaved": 5.00, "detail": {}},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["savings_total"] == Decimal("5.00")
|
||||
|
||||
def test_alternative_tax_field(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-TAX",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={"salesTax": 3.25, "detail": {}},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["tax"] == Decimal("3.25")
|
||||
|
||||
def test_alternative_quantity_field_qty(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-QTY",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{"description": "APPLES", "qty": 5, "basePrice": 1.00, "totalPrice": 5.00}
|
||||
],
|
||||
"total": 5.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["items"][0]["quantity"] == Decimal("5")
|
||||
|
||||
def test_alternative_upc_field_kroger_product_id(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-UPC",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{
|
||||
"description": "ITEM",
|
||||
"krogerProductId": "12345678",
|
||||
"basePrice": 1.00,
|
||||
"totalPrice": 1.00,
|
||||
}
|
||||
],
|
||||
"total": 1.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["items"][0]["upc"] == "12345678"
|
||||
|
||||
def test_missing_extended_price_computed(self):
|
||||
"""When totalPrice is missing, extended_price = unit_price * quantity."""
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-CALC",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"description": "EGGS", "basePrice": 5.49, "quantity": 2}],
|
||||
"total": 10.98,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["items"][0]["extended_price"] == Decimal("5.49") * Decimal("2")
|
||||
|
||||
|
||||
class TestMeijerFieldNameVariations:
|
||||
"""Meijer XHR endpoints may change field names between SPA versions."""
|
||||
|
||||
def test_alternative_item_key_line_items(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-ALT-1",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"lineItems": [{"description": "BANANAS", "price": 0.69, "extendedPrice": 0.69}],
|
||||
"total": 0.69,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["product_name_raw"] == "BANANAS"
|
||||
|
||||
def test_alternative_description_fields(self):
|
||||
for field in ("itemDescription", "name"):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-DESC",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{field: "TEST ITEM", "price": 1.00, "extendedPrice": 1.00}],
|
||||
"total": 1.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["product_name_raw"] == "TEST ITEM"
|
||||
|
||||
def test_alternative_price_field_unit_price(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-PRICE",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"description": "MILK", "unitPrice": 3.49, "totalPrice": 3.49}],
|
||||
"total": 3.49,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["unit_price"] == Decimal("3.49")
|
||||
|
||||
def test_alternative_extended_price_field_total_price(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-EXT",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"description": "CEREAL", "price": 4.99, "totalPrice": 4.99}],
|
||||
"total": 4.99,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["extended_price"] == Decimal("4.99")
|
||||
|
||||
def test_alternative_total_field_transaction_total(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-TOT",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={"transactionTotal": 55.00, "detail": {}},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["total"] == Decimal("55.00")
|
||||
|
||||
def test_alternative_loyalty_field(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-LOY",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{
|
||||
"description": "ITEM",
|
||||
"price": 5.00,
|
||||
"extendedPrice": 5.00,
|
||||
"loyaltyDiscount": 0.50,
|
||||
}
|
||||
],
|
||||
"total": 5.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["loyalty_discount"] == Decimal("0.50")
|
||||
|
||||
def test_alternative_upc_field_uppercase(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-UPC",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{
|
||||
"description": "ITEM",
|
||||
"UPC": "0012345678",
|
||||
"price": 1.00,
|
||||
"extendedPrice": 1.00,
|
||||
}
|
||||
],
|
||||
"total": 1.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["upc"] == "12345678"
|
||||
|
||||
def test_alternative_category_field(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-CAT",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{
|
||||
"description": "ITEM",
|
||||
"price": 1.00,
|
||||
"extendedPrice": 1.00,
|
||||
"departmentDescription": "FROZEN",
|
||||
}
|
||||
],
|
||||
"total": 1.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["category_raw"] == "FROZEN"
|
||||
|
||||
def test_missing_extended_price_computed(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-CALC",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"description": "MILK", "price": 3.49, "quantity": 2}],
|
||||
"total": 6.98,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["extended_price"] == Decimal("3.49") * Decimal("2")
|
||||
|
||||
def test_missing_description_fallback(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-NODESC",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [{"price": 1.00, "extendedPrice": 1.00}],
|
||||
"total": 1.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["product_name_raw"] == "UNKNOWN ITEM"
|
||||
|
||||
|
||||
class TestMixedFieldVersions:
|
||||
"""Test receipts that mix field naming conventions (happens during rollouts)."""
|
||||
|
||||
def test_kroger_mixed_item_fields(self):
|
||||
"""Some items use old names, some use new names in same receipt."""
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-MIX",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{"description": "OLD STYLE", "basePrice": 2.00, "totalPrice": 2.00},
|
||||
{"productName": "NEW STYLE", "unitPrice": 3.00, "extendedAmount": 3.00},
|
||||
],
|
||||
"total": 5.00,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert len(result["items"]) == 2
|
||||
assert result["items"][0]["product_name_raw"] == "OLD STYLE"
|
||||
assert result["items"][0]["unit_price"] == Decimal("2.00")
|
||||
assert result["items"][1]["product_name_raw"] == "NEW STYLE"
|
||||
assert result["items"][1]["unit_price"] == Decimal("3.00")
|
||||
|
||||
def test_kroger_completely_unknown_structure_no_crash(self):
|
||||
"""Receipt with unrecognized structure should return empty items."""
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-UNKNOWN",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={"something_unexpected": [1, 2, 3], "detail": {"foo": "bar"}},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["receipt_id"] == "KR-UNKNOWN"
|
||||
assert result["items"] == []
|
||||
|
||||
def test_meijer_completely_unknown_structure_no_crash(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-UNKNOWN",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={"something_unexpected": [1, 2, 3], "detail": {"foo": "bar"}},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["receipt_id"] == "MJ-UNKNOWN"
|
||||
assert result["items"] == []
|
||||
|
||||
def test_kroger_null_fields_no_crash(self):
|
||||
"""Fields with None values should be handled gracefully."""
|
||||
raw = RawReceipt(
|
||||
receipt_id="KR-NULL",
|
||||
purchase_date="2026-03-12",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{
|
||||
"description": "ITEM",
|
||||
"basePrice": None,
|
||||
"totalPrice": None,
|
||||
"quantity": None,
|
||||
"upc": None,
|
||||
"department": None,
|
||||
}
|
||||
],
|
||||
"total": None,
|
||||
"subtotal": None,
|
||||
"tax": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_kroger_receipt(raw)
|
||||
assert result["items"][0]["product_name_raw"] == "ITEM"
|
||||
assert result["items"][0]["unit_price"] == Decimal("0")
|
||||
|
||||
def test_meijer_null_fields_no_crash(self):
|
||||
raw = RawReceipt(
|
||||
receipt_id="MJ-NULL",
|
||||
purchase_date="2026-03-10",
|
||||
raw_data={
|
||||
"detail": {
|
||||
"items": [
|
||||
{
|
||||
"description": "ITEM",
|
||||
"price": None,
|
||||
"extendedPrice": None,
|
||||
"quantity": None,
|
||||
"upc": None,
|
||||
"category": None,
|
||||
}
|
||||
],
|
||||
"total": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
result = parse_meijer_receipt(raw)
|
||||
assert result["items"][0]["product_name_raw"] == "ITEM"
|
||||
assert result["items"][0]["unit_price"] == Decimal("0")
|
||||
Reference in New Issue
Block a user