backend store_catalog updates
This commit is contained in:
268
tests/test_generic_products.py
Normal file
268
tests/test_generic_products.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Tests for unbranded-commodity detection and the own-products bucket.
|
||||
|
||||
There was no test anywhere that fed `infer_brand` a name with no brand in it,
|
||||
which is exactly why "Salt 1kg" had been filing itself under Colgate-Palmolive
|
||||
and "Red Chilli Powder" under Brooke Bond - complete with Brooke Bond's real
|
||||
FSSAI licence number. The first two sections below are the regression wall for
|
||||
that, and the third is the property that keeps this from over-reaching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import store_catalog_pipeline as pipeline
|
||||
from app.services import price_estimator
|
||||
from app.services.category_registry import detect_category_from_text
|
||||
from app.services.generic_products import (
|
||||
OWN_PRODUCTS_BRAND,
|
||||
canonical_category,
|
||||
is_unbranded,
|
||||
)
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
|
||||
def _sheet(names) -> bytes:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["Item Name"])
|
||||
for name in names:
|
||||
ws.append([name])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_sku_counter(tmp_path, monkeypatch):
|
||||
from app.services import sku_service
|
||||
monkeypatch.setattr(sku_service, "_data_dir", tmp_path / "sku_sequences")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(monkeypatch):
|
||||
"""Captures (brand, row) pairs so a test can assert where a row landed."""
|
||||
written: list = []
|
||||
|
||||
def fake_upsert(brand, rows, cleanup=False):
|
||||
assert cleanup is False, "cleanup=True would delete the brand's existing catalog"
|
||||
for row in rows:
|
||||
written.append((brand, row))
|
||||
return len(rows)
|
||||
|
||||
monkeypatch.setattr(pipeline, "upsert_brand_products", fake_upsert)
|
||||
monkeypatch.setattr(pipeline, "get_products_by_brand", lambda b, **kw: [])
|
||||
monkeypatch.setattr(pipeline, "embed_texts", lambda texts: [[0.0] * 384 for _ in texts])
|
||||
return written
|
||||
|
||||
|
||||
def _brand_of(names, store):
|
||||
"""Run one sheet and return the brand the (single) input row landed under.
|
||||
|
||||
Matches on the ORIGINAL name rather than the stored `product_name`: stage 4
|
||||
explodes a row into one per pack size and appends that size to the name, so
|
||||
"Idhayam Sesame Oil" is stored as "Idhayam Sesame Oil 500ml".
|
||||
"""
|
||||
pipeline.run_pipeline("store.xlsx", _sheet(names), use_llm=False, fetch_images=False)
|
||||
brands = {brand for brand, _row in store}
|
||||
assert len(brands) == 1, f"expected one destination brand, got {brands}"
|
||||
return brands.pop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The misroutes this exists to stop
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("name", [
|
||||
"Salt 1kg", # matched alias "colgate active salt"
|
||||
"Milk 1L", # matched alias "cadbury dairy milk"
|
||||
"Butter 500g", # matched alias "nestle butter"
|
||||
"Ghee 1L",
|
||||
"Paneer 200g",
|
||||
"Cheese Slices",
|
||||
"Tea Powder 250g",
|
||||
"Red Chilli Powder 100g", # matched alias "brooke bond red label"
|
||||
"Oil 1L",
|
||||
])
|
||||
def test_a_commodity_is_not_filed_under_a_real_brand(name, store):
|
||||
"""Each of these used to be written into a live brand's catalog because one
|
||||
commodity noun matched a whole word inside a multi-word alias."""
|
||||
assert _brand_of([name], store) == OWN_PRODUCTS_BRAND
|
||||
|
||||
|
||||
def test_a_commodity_never_carries_someone_elses_fssai_licence(store):
|
||||
"""The compliance half of the bug: the misresolved parent's real licence
|
||||
number was copied onto the row."""
|
||||
pipeline.run_pipeline("s.xlsx", _sheet(["Red Chilli Powder 100g", "Milk 1L", "Butter 500g"]), use_llm=False, fetch_images=False)
|
||||
assert [row["fssai_license"] for _brand, row in store] == [None, None, None]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"Toor Dhal 1kg", "Sugar 1kg", "Black Pepper 100g", "Rice 5kg",
|
||||
"Urad Dal 500g", "Wheat Flour 5kg", "Jaggery 500g", "Groundnut Oil 1L",
|
||||
])
|
||||
def test_a_commodity_does_not_spawn_its_own_brand_table(name, store):
|
||||
"""These used to become brand_toor, brand_sugar, brand_black, brand_rice -
|
||||
one junk table per leading word."""
|
||||
assert _brand_of([name], store) == OWN_PRODUCTS_BRAND
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The property that keeps this from over-reaching
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("Amul Butter 500g", "amul"),
|
||||
("Cadbury Dairy Milk Silk", "cadbury"),
|
||||
("Britannia Good Day Cashew Cookies 200g", "britannia"),
|
||||
("Tata Salt 1kg", "hindustan unilever"), # a real alias, not a misroute
|
||||
("Colgate Active Salt 200g", "colgate-palmolive"),
|
||||
])
|
||||
def test_a_branded_product_keeps_its_own_brand(name, expected, store):
|
||||
"""One token that is not a commodity means "this is a brand". Without this,
|
||||
the fix would be worse than the bug."""
|
||||
assert _brand_of([name], store) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"Bikaji Aloo Bhujia 200g", "Aachi Sambar Powder", "Idhayam Sesame Oil",
|
||||
"Lion Dates 450g", "Naga Maida 2kg",
|
||||
])
|
||||
def test_a_brand_absent_from_the_alias_map_is_not_swallowed(name, store):
|
||||
"""BRAND_ALIASES holds only 231 mostly-large FMCG names. These are real
|
||||
brands missing from it, and "unknown" must never mean "unbranded"."""
|
||||
assert _brand_of([name], store) != OWN_PRODUCTS_BRAND
|
||||
|
||||
|
||||
def test_qualifiers_do_not_make_a_commodity_look_branded():
|
||||
assert is_unbranded("Organic Toor Dal Whole 1kg")
|
||||
assert is_unbranded("Premium Sortex Basmati Rice 5kg")
|
||||
assert is_unbranded("Rock Salt 1kg")
|
||||
|
||||
|
||||
def test_a_supplied_brand_always_wins():
|
||||
assert not is_unbranded("Sugar 1kg", sheet_brand="Madhur")
|
||||
|
||||
|
||||
def test_a_blank_brand_column_is_an_explicit_statement():
|
||||
"""The sheet troubled itself to include the column and left it empty. This
|
||||
was previously the one case rejected outright."""
|
||||
assert is_unbranded("Anything At All", brand_column_supplied=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The rest of the row: category and price
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("name,category", [
|
||||
("Toor Dhal 1kg", "Pulses, Grains & Spices"),
|
||||
("Sugar 1kg", "Sugar & Jaggery"),
|
||||
("Salt 1kg", "Salt & Staples"),
|
||||
("Black Pepper 100g", "Spices & Masalas"),
|
||||
("Rice 5kg", "Atta & Staples"),
|
||||
])
|
||||
def test_a_commodity_resolves_to_a_real_category(name, category):
|
||||
"""These all fell through to "General" before, which disabled HSN lookup and
|
||||
the pack-size unit rulebook along with it."""
|
||||
assert detect_category_from_text(name) == category
|
||||
assert canonical_category(name) is not None
|
||||
|
||||
|
||||
def test_the_category_carries_an_hsn_code(store):
|
||||
"""The names were chosen to match HSN_GST_TABLE keys, so tax enrichment
|
||||
works without a second mapping."""
|
||||
_brand_of(["Toor Dhal 1kg"], store)
|
||||
assert store[0][1]["hsn_code"] == "0713"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,size,ceiling", [
|
||||
("Sugar 1kg", "1kg", 100), # was ₹120-280 under the "general" band
|
||||
("Salt 1kg", "1kg", 60), # was ₹120-280
|
||||
("Turmeric Powder 100g", "100g", 80),
|
||||
])
|
||||
def test_a_staple_is_no_longer_priced_like_branded_fmcg(name, size, ceiling):
|
||||
lo, _hi = price_estimator.estimate_price_range_for_size(
|
||||
size, name, OWN_PRODUCTS_BRAND, detect_category_from_text(name) or ""
|
||||
)
|
||||
assert lo < ceiling, f"{name} still prices at ₹{lo}, above the {ceiling} sanity ceiling"
|
||||
|
||||
|
||||
def test_pepper_is_not_priced_like_a_cheap_staple():
|
||||
"""The opposite failure: spices are genuinely expensive by weight and used
|
||||
to come out at ₹20-45 per 100g."""
|
||||
lo, _hi = price_estimator.estimate_price_range_for_size(
|
||||
"100g", "Black Pepper 100g", OWN_PRODUCTS_BRAND, "Spices & Masalas"
|
||||
)
|
||||
assert lo > 50
|
||||
|
||||
|
||||
def test_staples_get_staple_pack_sizes():
|
||||
"""100g/250g/500g is not how dal, rice or salt is sold."""
|
||||
assert price_estimator.default_size_variants("Pulses, Grains & Spices", "Toor Dal") == ["500g", "1kg", "5kg"]
|
||||
assert price_estimator.default_size_variants("Salt & Staples", "Salt") == ["1kg"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table naming and visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_the_bucket_round_trips_to_its_table_and_back():
|
||||
"""The frontend passes the display name straight back to
|
||||
/api/brands/{brand}/products, so this has to be an identity."""
|
||||
from app.services.brand_registry import resolve_parent_brand
|
||||
from app.services.vector_store import _sanitize_name, display_name_for_suffix
|
||||
|
||||
suffix = _sanitize_name(resolve_parent_brand(OWN_PRODUCTS_BRAND))
|
||||
assert suffix == "own_products"
|
||||
assert display_name_for_suffix(suffix) == OWN_PRODUCTS_BRAND
|
||||
|
||||
|
||||
def test_the_bucket_survives_an_active_brands_whitelist(monkeypatch):
|
||||
"""conftest blanks ACTIVE_BRANDS for the whole suite, so nothing else here
|
||||
runs under the value production actually ships. ACTIVE_BRANDS is a
|
||||
whitelist: without the bucket in it, the table is invisible everywhere."""
|
||||
from app.services import active_brands
|
||||
|
||||
monkeypatch.setattr(active_brands, "_RAW_ACTIVE_BRANDS", "Amul,Cadbury,Own Products")
|
||||
active_brands.invalidate()
|
||||
try:
|
||||
assert active_brands.is_active_suffix("own_products")
|
||||
assert not active_brands.is_active_suffix("toor")
|
||||
finally:
|
||||
active_brands.invalidate()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The migration for rows already misfiled
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_the_migration_selects_only_unbranded_rows():
|
||||
"""The safety property. It runs over the REAL brand catalogs looking for
|
||||
misfiled commodities, so it must never pick up a genuine product."""
|
||||
from scripts.migrate_own_products import is_unbranded as predicate
|
||||
|
||||
# These are the rows sitting in brand_nestle / brand_amul / brand_cadbury.
|
||||
assert predicate("Butter 500g")
|
||||
assert predicate("Milk 1L")
|
||||
assert predicate("Salt 1kg")
|
||||
# These are their legitimate neighbours in the same tables.
|
||||
assert not predicate("Amul Butter 500g")
|
||||
assert not predicate("Nestle Munch Chocolate")
|
||||
assert not predicate("Cadbury Dairy Milk Silk 150g")
|
||||
|
||||
|
||||
def test_a_migrated_row_is_rebuilt_for_its_new_home():
|
||||
from scripts.migrate_own_products import TARGET_SUFFIX, _rebuild_row
|
||||
|
||||
rebuilt = _rebuild_row({
|
||||
"product_name": "Red Chilli Powder 100g",
|
||||
"size_variants": ["100g"],
|
||||
"category": "General",
|
||||
"image_id": "brooke_bond_red_chilli_powder_100g",
|
||||
"image_url": "https://cdn.example.com/brooke_bond/x.jpg",
|
||||
"image_urls": ["https://cdn.example.com/brooke_bond/x.jpg"],
|
||||
"fssai_license": "10013022001897", # Brooke Bond's, not this product's
|
||||
})
|
||||
|
||||
assert rebuilt["fssai_license"] is None, "another company's licence must not travel"
|
||||
assert rebuilt["image_id"].startswith(TARGET_SUFFIX)
|
||||
assert rebuilt["image_urls"] == [] and rebuilt["image_url"] is None
|
||||
assert rebuilt["category"] == "Spices & Masalas"
|
||||
assert rebuilt["price_range"].startswith("₹")
|
||||
Reference in New Issue
Block a user