19ee695c20
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
5.6 KiB
Python
165 lines
5.6 KiB
Python
"""§6.5.1 row validation — every row-error rule has a fixture (SD-0002 §6.8)."""
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from app.domains.products.codec import parse_csv
|
|
from app.domains.products.validate import build_products
|
|
|
|
|
|
def _products(*lines: str):
|
|
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
|
|
|
|
|
def _errors(*lines: str):
|
|
return [e for p in _products(*lines) for e in p.errors]
|
|
|
|
|
|
def test_simple_product_parses_clean():
|
|
[p] = _products(
|
|
"Handle,Title,Vendor,Tags,Status,Published,Variant Price,Variant SKU",
|
|
"moon-mug,Moon Mug,Acme,\"kitchen, mugs\",active,TRUE,18.00,SKU-1",
|
|
)
|
|
assert p.valid and p.handle == "moon-mug" and p.title == "Moon Mug"
|
|
assert p.fields["tags"] == ["kitchen", "mugs"]
|
|
assert p.fields["status"] == "active" and p.fields["published"] is True
|
|
[v] = p.variants
|
|
assert v.options == (None, None, None)
|
|
assert v.fields["price"] == Decimal("18.00") and v.fields["sku"] == "SKU-1"
|
|
|
|
|
|
def test_option_product_groups_consecutive_rows():
|
|
[p] = _products(
|
|
"Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
|
"tee,Tee,Size,S,24.00",
|
|
"tee,,,M,24.00",
|
|
"tee,,,L,26.00",
|
|
)
|
|
assert p.valid and p.option_names == ("Size", None, None)
|
|
assert [v.options[0] for v in p.variants] == ["S", "M", "L"]
|
|
|
|
|
|
def test_image_only_rows_and_dedupe():
|
|
[p] = _products(
|
|
"Handle,Title,Image Src,Image Position,Image Alt Text",
|
|
"mug,Mug,https://x/a.jpg,1,front",
|
|
"mug,,https://x/b.jpg,2,back",
|
|
"mug,,https://x/a.jpg,3,dupe",
|
|
)
|
|
assert p.valid
|
|
assert [(i.source_url, i.position) for i in p.images] == [
|
|
("https://x/a.jpg", 1), ("https://x/b.jpg", 2),
|
|
]
|
|
|
|
|
|
def test_variant_image_joins_product_images():
|
|
[p] = _products(
|
|
"Handle,Title,Variant Image",
|
|
"mug,Mug,https://x/v.jpg",
|
|
)
|
|
assert p.variants[0].fields["variant_image"] == "https://x/v.jpg"
|
|
assert [i.source_url for i in p.images] == ["https://x/v.jpg"]
|
|
|
|
|
|
def test_description_sanitized_inv15():
|
|
[p] = _products(
|
|
"Handle,Title,Description",
|
|
'mug,Mug,"<p onclick=\'x()\'>hi</p><script>evil()</script>"',
|
|
)
|
|
html = p.fields["description_html"]
|
|
assert "<p>" in html and "script" not in html and "onclick" not in html
|
|
|
|
|
|
def test_blank_cell_clears_absent_column_missing():
|
|
[p] = _products("Handle,Title,Vendor", "mug,Mug,")
|
|
assert p.fields["vendor"] is None # present-but-empty == clear
|
|
assert "status" not in p.fields # absent column == untouched
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"header,row,column,fragment",
|
|
[
|
|
("Handle,Title", ",NoHandle", "Handle", "needs a Handle"),
|
|
("Handle,Title", "Bad_Handle!,T", "Handle", "isn't a valid handle"),
|
|
("Handle,Title", "mug,", "Title", "missing its Title"),
|
|
("Handle,Title,Type", "mug,Mug,kit_virtual", "Type", "kits arrive"),
|
|
("Handle,Title,Component 1 SKU", "mug,Mug,ABC", "Component 1 SKU", "kits arrive"),
|
|
("Handle,Title,Status", "mug,Mug,live", "Status", "is not a status"),
|
|
("Handle,Title,Published", "mug,Mug,YES", "Published", "is not TRUE or FALSE"),
|
|
("Handle,Title,Variant Price", 'mug,Mug,"12,50"', "Variant Price", "is not a price"),
|
|
("Handle,Title,Variant Cost", "mug,Mug,-3", "Variant Cost", "is not a price"),
|
|
("Handle,Title,Variant Weight", "mug,Mug,heavy", "Variant Weight", "is not a number"),
|
|
("Handle,Title,Variant Inventory Qty", "mug,Mug,3.5", "Variant Inventory Qty", "is not a whole number"),
|
|
("Handle,Title,Variant Position", "mug,Mug,0", "Variant Position", "is not a position"),
|
|
("Handle,Title,Option1 Value", "mug,Mug,Red", "Option1 Value", "no Option1 Name"),
|
|
],
|
|
)
|
|
def test_row_error_rules(header, row, column, fragment):
|
|
errors = _errors(header, row)
|
|
assert any(e.column == column and fragment in e.message for e in errors), errors
|
|
|
|
|
|
def test_missing_option_value_for_named_option():
|
|
errors = _errors(
|
|
"Handle,Title,Option1 Name,Option1 Value,Variant SKU",
|
|
"tee,Tee,Size,S,A",
|
|
"tee,,,,B",
|
|
)
|
|
assert any("missing its Option1 Value" in e.message and e.line_number == 3 for e in errors)
|
|
|
|
|
|
def test_duplicate_option_combo_is_error():
|
|
errors = _errors(
|
|
"Handle,Title,Option1 Name,Option1 Value",
|
|
"tee,Tee,Size,S",
|
|
"tee,,,S",
|
|
)
|
|
assert any("duplicate variant" in e.message for e in errors)
|
|
|
|
|
|
def test_second_variant_on_no_option_product_is_error():
|
|
errors = _errors(
|
|
"Handle,Title,Variant SKU",
|
|
"mug,Mug,A",
|
|
"mug,,B",
|
|
)
|
|
assert any("without options can have only one variant" in e.message for e in errors)
|
|
|
|
|
|
def test_non_consecutive_handle_is_error():
|
|
errors = _errors(
|
|
"Handle,Title",
|
|
"mug,Mug",
|
|
"tee,Tee",
|
|
"mug,",
|
|
)
|
|
assert any("must be consecutive" in e.message and e.line_number == 4 for e in errors)
|
|
|
|
|
|
def test_no_data_row_is_error():
|
|
errors = _errors(
|
|
"Handle,Title,Variant SKU,Image Src",
|
|
"mug,Mug,A,",
|
|
"mug,,,",
|
|
)
|
|
assert any("no variant or image data" in e.message for e in errors)
|
|
|
|
|
|
def test_errors_poison_their_product_only():
|
|
products = _products(
|
|
"Handle,Title,Variant Price",
|
|
"good-mug,Mug,10.00",
|
|
"bad-tee,Tee,not-a-price",
|
|
)
|
|
by_handle = {p.handle: p for p in products}
|
|
assert by_handle["good-mug"].valid
|
|
assert not by_handle["bad-tee"].valid
|
|
|
|
|
|
def test_all_errors_collected_not_first_only():
|
|
[p] = _products(
|
|
"Handle,Title,Status,Variant Price",
|
|
"mug,,bogus,abc",
|
|
)
|
|
assert len(p.errors) == 3 # missing Title + bad status + bad price
|