diff --git a/src/monitor_utils/database.py b/src/monitor_utils/database.py index 3a0b9de..9c95c4b 100644 --- a/src/monitor_utils/database.py +++ b/src/monitor_utils/database.py @@ -347,6 +347,11 @@ def convert_product_to_mongo(product: Product) -> dict: "customizationIndex": metadata["customization_index"], "memberNonMemberComparison": metadata["member_non_member_comparison"], "overstockText": metadata["overstock_text"], + "affiliateUrl": metadata["affiliate_url"], + "promoCode": metadata["promo_code"], + "promoPercentage": metadata["promo_percentage"], + "promoDollarOff": metadata["promo_dollar_off"], + "promoMinSpend": metadata["promo_min_spend"], "startDate": timing["start_date"], "endDate": timing["end_date"], "priceBeforeDrop": pricing["price_before_drop"], diff --git a/src/monitor_utils/runner.py b/src/monitor_utils/runner.py index a9d528e..0358772 100644 --- a/src/monitor_utils/runner.py +++ b/src/monitor_utils/runner.py @@ -116,6 +116,27 @@ async def setup_config( return category_names, config["channels"], collection +def combined_discount(base_percentage: int, promo_percentage: int | None) -> int: + """Combine an on-sale discount with a percentage code applied at checkout. + + A "extra N% off" code discounts the already-reduced sale price, so the two + compound rather than add: 20% off then an extra 30% code is 44% off list, + not 50%. Returns base_percentage unchanged when there is no percentage code. + + Args: + base_percentage: The on-sale discount off list, as a whole percent. + promo_percentage: The code's advertised percent off, or None. + + Returns: + The combined discount off list, rounded to a whole percent. + """ + + if not promo_percentage: + return base_percentage + + return round(100 - (100 - base_percentage) * (100 - promo_percentage) / 100) + + def build_product_details( product: dict, min_percentage: int, @@ -148,7 +169,11 @@ def build_product_details( percentage = int(((original_price - current_price) / original_price) * 100) price_drop = original_price - current_price - passes_percentage = percentage >= min_percentage + # A percentage code compounds onto the sale price, so an item can clear the + # threshold on the combined discount even when the base discount would not. + # Dollar codes do not map to a percent and never affect the threshold. + effective_percentage = combined_discount(percentage, product.get("promoPercentage")) + passes_percentage = effective_percentage >= min_percentage passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop if not passes_percentage and not passes_price_drop: return @@ -164,6 +189,10 @@ def build_product_details( overstock_text=product["overstockText"], percentage=percentage, affiliate_url=product.get("affiliateUrl"), + promo_code=product.get("promoCode"), + promo_percentage=product.get("promoPercentage"), + promo_dollar_off=product.get("promoDollarOff"), + promo_min_spend=product.get("promoMinSpend"), ), timing=ProductTiming( discovered_date=product["discoveredDate"], @@ -351,7 +380,7 @@ async def execute_pagination( async def fetch_page(page_index: int): """Fetch a single page or offset, optionally under the shared semaphore.""" - async with (semaphore or nullcontext()): + async with semaphore or nullcontext(): if stop_event.is_set(): return diff --git a/tests/test_build_product_details.py b/tests/test_build_product_details.py new file mode 100644 index 0000000..69ad098 --- /dev/null +++ b/tests/test_build_product_details.py @@ -0,0 +1,105 @@ +"""unit test: build_product_details thresholding, promo read, and combined_discount + +run from the repo root: + uv run pytest tests/test_build_product_details.py + +pure and offline — no Mongo. Builds a Product from a raw doc dict and checks the +discount threshold (with percentage codes compounding onto the sale price), the +promo fields read through via .get, and the combined_discount helper. +""" + +from datetime import datetime, timezone + +from monitor_utils.runner import build_product_details, combined_discount + + +def _doc(**overrides) -> dict: + """A well-formed product doc; override prices/promo per test.""" + doc = { + "_id": "p1_tools", + "title": "Widget", + "url": "http://x/p1", + "currentPrice": 80.0, + "originalPrice": 100.0, + "imageUrl": "http://img/1", + "customizationIndex": None, + "memberNonMemberComparison": None, + "overstockText": None, + "discoveredDate": datetime(2026, 7, 26, tzinfo=timezone.utc), + "startDate": None, + "endDate": None, + "priceBeforeDrop": None, + "eduPrice": None, + "available": True, + "condition": None, + "appExclusive": None, + "memberPick": None, + "memberDeal": None, + } + doc.update(overrides) + return doc + + +# ------------------------------------------------------------ combined_discount + + +def test_combined_discount_no_promo_is_base(): + assert combined_discount(24, None) == 24 + assert combined_discount(24, 0) == 24 + + +def test_combined_discount_compounds_not_adds(): + # 20% off then extra 30% code -> 44% off list (customer pays $56), not 50% + assert combined_discount(20, 30) == 44 + + +def test_combined_discount_rounds_to_nearest(): + # 24% sale + 22% code: 1 - .76*.78 = .4072 -> 41 (rounded, not truncated 40) + assert combined_discount(24, 22) == 41 + + +# --------------------------------------------------------- build_product_details + + +def test_promo_fields_read_through(): + doc = _doc( + promoCode="BIRTHDAY26", + promoPercentage=22, + promoDollarOff=None, + promoMinSpend=None, + ) + product = build_product_details(doc, min_percentage=0) + assert product.metadata.promo_code == "BIRTHDAY26" + assert product.metadata.promo_percentage == 22 + # base percentage is the raw price delta, unchanged by the code + assert product.metadata.percentage == 20 + + +def test_missing_promo_keys_default_to_none(): + """a doc from a lagging monitor lacks promo keys -> None, not KeyError""" + product = build_product_details(_doc(), min_percentage=0) + assert product.metadata.promo_code is None + assert product.metadata.promo_percentage is None + + +def test_percentage_code_lifts_item_over_threshold(): + # 30% base fails min 40, but +22% code -> 45% combined clears it + doc = _doc(currentPrice=70.0, originalPrice=100.0, promoPercentage=22) + assert build_product_details(doc, min_percentage=40) is not None + + +def test_below_threshold_without_code_is_skipped(): + doc = _doc(currentPrice=70.0, originalPrice=100.0) # 30%, no code + assert build_product_details(doc, min_percentage=40) is None + + +def test_dollar_code_does_not_affect_threshold(): + # a dollar code has no promoPercentage, so 30% base stays below min 40 + doc = _doc( + currentPrice=70.0, + originalPrice=100.0, + promoCode="STORE22", + promoDollarOff=22, + promoMinSpend=100, + ) + assert build_product_details(doc, min_percentage=40) is None