"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8).""" from decimal import Decimal from app.domains.products.codec import parse_csv from app.domains.products.diff import ( CatalogImage, CatalogProduct, CatalogVariant, compute_diff, ) from app.domains.products.validate import build_products def _canon(*lines: str): return build_products(parse_csv(("\n".join(lines) + "\n").encode())) def _catalog_mug(**overrides): fields = { "title": "Moon Mug", "description_html": None, "vendor": "Acme", "product_type": "standalone", "google_product_category": None, "tags": ["kitchen"], "status": "active", "published": True, } | overrides return { "moon-mug": CatalogProduct( id=1, handle="moon-mug", title=fields["title"], option_names=(None, None, None), fields=fields, variants=[CatalogVariant(id=10, options=(None, None, None), position=1, fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"), "cost": None, "weight": None, "weight_unit": None, "volume": None, "volume_unit": None, "tax_id_1": None, "tax_id_2": None, "inventory_tracker": None, "inventory_qty": 40, "variant_image": None})], images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)], ) } HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src" MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg' def test_new_handle_classifies_add(): diff = compute_diff({}, _canon(HEADER, MUG_ROW)) [rec] = diff.records assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1 assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0} assert rec["detail"]["set"]["status"] == "active" def test_identical_file_classifies_unchanged(): diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW)) assert diff.records[0]["kind"] == "unchanged" assert diff.summary["unchanged"] == 1 def test_changed_price_classifies_update_with_before_after(): row = MUG_ROW.replace("18.00", "21.00") diff = compute_diff(_catalog_mug(), _canon(HEADER, row)) [rec] = diff.records assert rec["kind"] == "update" [vchange] = rec["detail"]["variants"] assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"] def test_absent_column_untouched_empty_cell_clears(): # Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change). diff = compute_diff( _catalog_mug(), _canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src", "moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"), ) assert diff.records[0]["kind"] == "unchanged" def test_empty_cell_clear_shows_in_diff(): # Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1). diff = compute_diff( _catalog_mug(), _canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src", "moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"), ) [rec] = diff.records assert rec["kind"] == "update" assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"] def test_new_option_combo_is_variant_add_existing_untouched(): catalog = _catalog_mug() diff = compute_diff( catalog, _canon("Handle,Title,Option1 Name,Option1 Value,Variant Price", "moon-mug,Moon Mug,Size,Large,25.00"), ) [rec] = diff.records assert rec["kind"] == "update" kinds = [v.get("kind") for v in rec["detail"]["variants"]] assert "add" in kinds POSITION_HEADER = HEADER + ",Variant Position" def test_matching_variant_position_column_classifies_unchanged(): # position is an attribute on CatalogVariant (not in fields{}); the compare # must read it from there, not invent a before:None. diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",1")) assert diff.records[0]["kind"] == "unchanged" def test_changed_variant_position_reports_honest_before(): diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",2")) [rec] = diff.records assert rec["kind"] == "update" [ventry] = rec["detail"]["variants"] assert {"field": "position", "before": 1, "after": 2} in ventry["changes"] def test_blank_position_cell_resolves_to_file_order_unchanged(): # A present-but-empty Variant Position cell resets to file order (the spec's # "defaults to file order"), never to NULL — here file order matches the # catalog position, so nothing changes. diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",")) assert diff.records[0]["kind"] == "unchanged" def test_blank_position_cell_updates_to_file_order(): catalog = _catalog_mug() catalog["moon-mug"].variants[0].position = 2 diff = compute_diff(catalog, _canon(POSITION_HEADER, MUG_ROW + ",")) [rec] = diff.records assert rec["kind"] == "update" [ventry] = rec["detail"]["variants"] assert {"field": "position", "before": 2, "after": 1} in ventry["changes"] def _catalog_lamp_with_image(): # A well-formed single-variant product carrying one fetched image id=55. fields = { "title": "Lamp", "description_html": None, "vendor": "Acme", "product_type": "standalone", "google_product_category": None, "tags": ["home"], "status": "active", "published": True, } return { "lamp": CatalogProduct( id=1, handle="lamp", title="Lamp", option_names=(None, None, None), fields=fields, variants=[CatalogVariant(id=10, options=(None, None, None), position=1, fields={"sku": "SKU-L", "barcode": None, "price": Decimal("30.00"), "cost": None, "weight": None, "weight_unit": None, "volume": None, "volume_unit": None, "tax_id_1": None, "tax_id_2": None, "inventory_tracker": None, "inventory_qty": 5, "variant_image": None})], images=[CatalogImage(id=55, source_url="https://m.example/a.png", position=1, alt_text=None, status="fetched")], ) } LAMP_HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position" LAMP_ROW_PREFIX = "lamp,Lamp,Acme,home,active,SKU-L,30.00,5," def test_hosted_url_for_existing_image_is_unchanged_not_add(): # Catalog product "lamp" has a fetched image id=55; canonical re-imports it # as the hosted URL -> must be 'unchanged', never 'add'/'update'. diff = compute_diff( _catalog_lamp_with_image(), _canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/55/detail,1"), ) kinds = {r["handle"]: r["kind"] for r in diff.records} assert kinds["lamp"] == "unchanged" def test_hosted_url_for_unknown_id_is_row_error(): # Catalog "lamp" has NO images; canonical references /images/999/detail -> error. catalog = _catalog_lamp_with_image() catalog["lamp"].images = [] diff = compute_diff( catalog, _canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/999/detail,1"), ) kinds = {r["handle"]: r["kind"] for r in diff.records} assert kinds["lamp"] == "error" def test_error_product_classifies_error(): diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope")) [rec] = diff.records assert rec["kind"] == "error" assert rec["detail"]["errors"][0]["column"] == "Variant Price" def test_fingerprint_stable_and_drift_sensitive(): d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW)) d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW)) d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW)) assert d1.fingerprint == d2.fingerprint assert d1.fingerprint != d3.fingerprint