unpopular brand generation
This commit is contained in:
274
tests/test_brand_store.py
Normal file
274
tests/test_brand_store.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""A brand's own shop as a catalogue source.
|
||||
|
||||
WHY THIS SOURCE EXISTS
|
||||
----------------------
|
||||
`off_bulk.fetch_brand_corpus` is the only thing in the codebase that can
|
||||
ENUMERATE a brand's products; everything else verifies one product at a time
|
||||
and needs a name as input. For regional South Indian brands Open Food Facts is
|
||||
empty - measured 2026-09-10: Udhaiyam 0 hits, Gopuram 0, Tenali Double Horse 0,
|
||||
and both of Naga's 2 hits are a British pickle - so their catalogues were
|
||||
entirely `qwen2.5:1.5b` invention.
|
||||
|
||||
Their storefronts publish the real thing as structured JSON. Measured live:
|
||||
Aachi 161 products (159 with a VALID EAN-13), Double Horse 99, Gopuram 122,
|
||||
Anil 57 - against 83 for the same brands in Open Food Facts.
|
||||
|
||||
WHAT THESE TESTS GUARD
|
||||
----------------------
|
||||
This source is different in kind from every other one here: it ASSERTS a
|
||||
hundred products at once instead of verifying one. So the identity guard is the
|
||||
thing that matters most - pointing it at the wrong site imports a hundred of
|
||||
somebody else's real products under this brand's name, and they look flawless
|
||||
because they are flawless, just not theirs.
|
||||
|
||||
The fixtures are trimmed copies of real responses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import brand_store as bs
|
||||
from app.services.brand_registry import get_brand_store_domain, resolve_parent_brand
|
||||
|
||||
|
||||
# A real aachifoods.com /products.json record, trimmed.
|
||||
SHOPIFY_PRODUCT = {
|
||||
"title": "Soan Papdi Ghee",
|
||||
"product_type": "Snacks",
|
||||
"vendor": "Aachifoods",
|
||||
"images": [{"src": "https://cdn.shopify.com/s/files/1/x/ghee-soan-papdi.webp"}],
|
||||
"variants": [
|
||||
{"title": "200g", "price": "76.00", "sku": "8904209319340", "grams": 215},
|
||||
],
|
||||
}
|
||||
|
||||
# A real gopuramproducts.com Store API record, trimmed. Note the price.
|
||||
WOO_PRODUCT = {
|
||||
"name": "GOPURAM PREMIUM SAMBRAIN POWDER",
|
||||
"sku": "",
|
||||
"categories": [{"name": "Pooja Products"}],
|
||||
"images": [{"src": "https://gopuramproducts.com/wp-content/uploads/x.jpg"}],
|
||||
"prices": {"price": "5500", "currency_minor_unit": 2},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity - the guard that matters most
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_city_council_is_not_a_food_brand():
|
||||
"""THE CASE THIS GUARD EXISTS FOR.
|
||||
|
||||
`retail_presence.resolve_brand_domain("Naga")` really returns
|
||||
`cityofnagacebu.gov.ph` - the website of Naga City in the Philippines -
|
||||
because `_pick_brand_domain` accepted a domain whose stem merely CONTAINS a
|
||||
brand token. Here that would not cost one bad row; it would file a
|
||||
municipal website's contents as a Tamil Nadu brand's product list.
|
||||
"""
|
||||
assert bs._store_identity_ok("Naga", "cityofnagacebu.gov.ph", []) is False
|
||||
|
||||
|
||||
def test_a_brand_domain_is_the_brand_plus_a_qualifier():
|
||||
"""Prefix, not substring - that is the whole distinction.
|
||||
"gopuramproducts".startswith("gopuram") but "cityofnagacebu" does not
|
||||
start with "naga", though it contains it."""
|
||||
for brand, domain in [("Gopuram", "gopuramproducts.com"),
|
||||
("Udhaiyam", "udhaiyamdhall.com"),
|
||||
("Double Horse", "doublehorse.in"),
|
||||
("Naga", "nagafoods.in")]:
|
||||
assert bs._store_identity_ok(brand, domain, []) is True, domain
|
||||
|
||||
|
||||
def test_the_vendor_field_can_vouch_for_a_domain_that_does_not():
|
||||
"""Shopify states the seller. A brand trading on a domain that does not
|
||||
contain its name is still identifiable."""
|
||||
assert bs._store_identity_ok("Aachi", "someshop.com", ["Aachifoods"]) is True
|
||||
assert bs._store_identity_ok("Aachi", "someshop.com", ["Britannia"]) is False
|
||||
|
||||
|
||||
def test_a_rejected_catalogue_is_rejected_whole(monkeypatch):
|
||||
"""Half a foreign catalogue is not better than all of it."""
|
||||
monkeypatch.setattr(bs, "fetch_shopify",
|
||||
lambda d: [dict(SHOPIFY_PRODUCT, vendor="Someone Else")])
|
||||
monkeypatch.setattr(bs, "read_cached", lambda b: None)
|
||||
monkeypatch.setattr(bs, "_write_cache", lambda p, d: None)
|
||||
|
||||
assert bs.fetch_store_catalogue("Naga", "cityofnagacebu.gov.ph", live=True) is None
|
||||
|
||||
|
||||
def test_a_curated_domain_skips_the_heuristic(monkeypatch):
|
||||
"""A person already checked it. The heuristic cannot recognise every shape
|
||||
a real brand domain takes - "theanilgroup.com" is Anil's and neither starts
|
||||
with nor contains "anil" as a whole token - so applying it to a hand-verified
|
||||
mapping would reject good catalogues for looking unusual."""
|
||||
assert bs._store_identity_ok("Anil", "shop.theanilgroup.com", []) is False
|
||||
|
||||
monkeypatch.setattr(bs, "fetch_shopify", lambda d: [SHOPIFY_PRODUCT])
|
||||
monkeypatch.setattr(bs, "read_cached", lambda b: None)
|
||||
monkeypatch.setattr(bs, "_write_cache", lambda p, d: None)
|
||||
|
||||
rows = bs.fetch_store_catalogue("Anil", "shop.theanilgroup.com",
|
||||
live=True, trusted_domain=True)
|
||||
assert rows and len(rows) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_shopify_variant_carries_the_pack_size_and_a_real_barcode():
|
||||
rows = bs._shopify_candidates(SHOPIFY_PRODUCT)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["title"] == "Soan Papdi Ghee"
|
||||
assert row["category"] == "Snacks"
|
||||
assert row["size"] == "200g"
|
||||
assert row["price"] == 76.00
|
||||
assert row["image_url"].endswith("ghee-soan-papdi.webp")
|
||||
# A genuine EAN-13 on the Indian 890 GS1 prefix.
|
||||
assert row["barcode"] == "8904209319340"
|
||||
|
||||
|
||||
def test_a_shop_internal_sku_is_not_treated_as_a_barcode():
|
||||
"""Shops put anything in `sku`. `validate_barcode` decides, so an internal
|
||||
numbering scheme never reaches the barcode column - Gopuram leaves it blank
|
||||
and others use their own codes."""
|
||||
for junk in ("", "GOP-001", "12345"):
|
||||
product = json.loads(json.dumps(SHOPIFY_PRODUCT))
|
||||
product["variants"][0]["sku"] = junk
|
||||
assert bs._shopify_candidates(product)[0]["barcode"] is None, junk
|
||||
|
||||
|
||||
def test_default_title_is_not_a_pack_size():
|
||||
"""Shopify's placeholder for a product with no variant axis."""
|
||||
product = json.loads(json.dumps(SHOPIFY_PRODUCT))
|
||||
product["variants"][0]["title"] = "Default Title"
|
||||
product["variants"][0].pop("sku")
|
||||
|
||||
assert bs._shopify_candidates(product)[0]["size"] is None
|
||||
|
||||
|
||||
def test_woocommerce_prices_are_minor_units():
|
||||
"""gopuramproducts.com returns price "5500" with currency_minor_unit 2,
|
||||
which is Rs55.00 and not Rs5,500. Read straight, every price on the site is
|
||||
a hundred times too large - and `validate_price_range` would then reject
|
||||
every row, so the bug would present as "this brand is all wrong" rather
|
||||
than as a units error."""
|
||||
row = bs._woo_candidates(WOO_PRODUCT)[0]
|
||||
|
||||
assert row["price"] == 55.00
|
||||
assert row["category"] == "Pooja Products"
|
||||
assert row["title"] == "GOPURAM PREMIUM SAMBRAIN POWDER"
|
||||
|
||||
|
||||
def test_a_utf8_bom_does_not_break_parsing():
|
||||
"""WooCommerce really serves one; `resp.json()` raises on it."""
|
||||
class Resp:
|
||||
status_code = 200
|
||||
headers = {"content-type": "application/json; charset=UTF-8"}
|
||||
content = b"\xef\xbb\xbf" + json.dumps([WOO_PRODUCT]).encode()
|
||||
url = "https://example.com"
|
||||
|
||||
parsed = bs._json_or_none(Resp())
|
||||
|
||||
assert isinstance(parsed, list) and parsed[0]["name"].startswith("GOPURAM")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The sitemap tier states only what the site states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_sitemap_tier_never_invents_a_size_or_price():
|
||||
"""udhaiyamdhall.com is a brochure site: an <h1> and a photograph, no
|
||||
price, no pack size, no variants. Those stay None. A catalogue source that
|
||||
guesses them is the failure this whole area exists to fix."""
|
||||
import inspect
|
||||
source = inspect.getsource(bs.fetch_sitemap)
|
||||
|
||||
assert '"size": None' in source
|
||||
assert '"price": None' in source
|
||||
assert '"barcode": None' in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_double_horse_and_tenali_double_horse_stay_apart():
|
||||
"""TWO COMPANIES, ONE NAME. Double Horse is Manjilas of Kerala; Tenali
|
||||
Double Horse is an Andhra rice brand.
|
||||
|
||||
`_contains_word` matches whole words in BOTH directions, and "double horse"
|
||||
is a whole word sequence inside "tenali double horse". Registering only one
|
||||
of them moved the collision instead of fixing it - measured: with only the
|
||||
longer key present, resolve_parent_brand("Double Horse") returned
|
||||
"tenali double horse". Each direct entry protects the OTHER brand.
|
||||
"""
|
||||
assert resolve_parent_brand("Tenali Double Horse") == "tenali double horse"
|
||||
assert resolve_parent_brand("Double Horse") == "double horse"
|
||||
assert resolve_parent_brand("Tenali Double Horse") != resolve_parent_brand("Double Horse")
|
||||
|
||||
|
||||
def test_spelling_variants_reach_the_same_brand():
|
||||
assert resolve_parent_brand("Udhayam") == "udhaiyam"
|
||||
assert resolve_parent_brand("DoubleHorse") == "double horse"
|
||||
|
||||
|
||||
def test_a_brand_with_no_verified_store_has_none():
|
||||
"""Absent is the safe answer. A guessed domain is not - Naga's search
|
||||
result is a Philippine city council."""
|
||||
assert get_brand_store_domain("Naga") is None
|
||||
assert get_brand_store_domain("Tenali Double Horse") is None
|
||||
assert get_brand_store_domain("Gopuram") == "gopuramproducts.com"
|
||||
|
||||
|
||||
def test_existing_brands_are_unaffected():
|
||||
for brand, parent in [("Cadbury Dairy Milk", "cadbury"),
|
||||
("Haldiram", "haldirams"),
|
||||
("Sunfeast Yippee", "sunfeast")]:
|
||||
assert resolve_parent_brand(brand) == parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_store_row_outranks_open_food_facts():
|
||||
"""A manufacturer's own catalogue is first-party; Open Food Facts is
|
||||
crowd-sourced and, for these brands, absent or wrong."""
|
||||
from app.services import brand_discovery as bd
|
||||
|
||||
assert bd._score(["store"], None) > bd._score(["off"], "openfacts")
|
||||
assert bd._score(["store", "off"], "openfacts") == 1.0
|
||||
|
||||
|
||||
def test_a_store_row_survives_the_evidence_gate():
|
||||
"""WITHOUT THIS THE WHOLE SOURCE IS INVISIBLE. `require_evidence` drops any
|
||||
row with no `off` source and no evidence, and a store row has neither - it
|
||||
would score 0.25 and never reach the preview."""
|
||||
from app.services import brand_discovery as bd
|
||||
import inspect
|
||||
|
||||
gate = inspect.getsource(bd.discover_brand_products)
|
||||
assert '"store" not in product.sources' in gate
|
||||
|
||||
|
||||
def test_discovery_reads_the_store_cache_and_never_fetches(monkeypatch):
|
||||
"""Same split as the retail check: the backfill script fetches, discovery
|
||||
reads. A preview must not block on somebody's storefront."""
|
||||
from app.services import brand_discovery as bd
|
||||
|
||||
def explode(*_a, **_kw):
|
||||
raise AssertionError("discovery must not fetch a storefront")
|
||||
|
||||
monkeypatch.setattr(bs, "fetch_shopify", explode)
|
||||
monkeypatch.setattr(bs, "fetch_woocommerce", explode)
|
||||
monkeypatch.setattr(bs, "fetch_sitemap", explode)
|
||||
monkeypatch.setattr(bs, "read_cached", lambda b: [dict(SHOPIFY_PRODUCT, source="store")])
|
||||
|
||||
rows = bd._from_brand_store("Aachi")
|
||||
|
||||
assert rows and rows[0]["source"] == "store"
|
||||
Reference in New Issue
Block a user