Compare commits

..
11 Commits
Author SHA1 Message Date
dsqlandClaude Code Opus 5.5 6ea3187b33 build_product_details: skip non-finite prices instead of raising OverflowError
PR #25 guarded calculate_priority but missed the same calculation here.
On 2026-10-09 06:25:50Z the consumer hit a product with an infinite price;
runner.py:201

    percentage = int(((original_price - current_price) / original_price) * 100)

raised `OverflowError: cannot convert float infinity to integer`. The
Discord leg dropped that one alert, but the API leg's caller does not
catch OverflowError, so the exception escaped and the consumer logged
CRITICAL "api deal loop stopped" -- no deals reached the API from then on
(nothing sent at all after 22:00Z), while the bot itself stayed "alive".

build_product_details now returns None (with a warning, like the
existing missing-price skip) when either price is a non-finite float, on
BOTH paths: the MSRP-less path never computes a percentage and would
otherwise carry inf into the Product and the API payload. A finite pair
whose ratio still overflows (a subnormal original price) is skipped too.
Ints and other types keep their existing behaviour.

Tests (tests/test_build_product_details.py): five new cases fail on the
old code -- OverflowError, ValueError for nan, and the MSRP-less case
building a Product with inf -- and all 25 pass now. Ruff findings on the
two files are unchanged from main (5, all pre-existing).

Rebuild every service that installs monitor-utils with --no-cache to pick
this up (it is pulled unpinned from main).

Co-Authored-By: Claude Code Opus 5.5 (ansible-ops, auto-repair) <noreply@anthropic.com>
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-09 23:22:36 -04:00
dsqlandClaude Code Opus 5.5 f51aed2c9f calculate_priority: resolve non-finite prices to 0 instead of crashing the monitor
tpv-academysports died on 2026-10-08 06:27 UTC and stayed down: six
identical crashes in 8 minutes (each ~2 min after start), then docker's
on-failure:5 gave up. Every one was

    database.py, in calculate_priority
        percentage = int(((original_price - current_price) / original_price) * 100)
    OverflowError: cannot convert float infinity to integer

A monitor builds prices with float() on the retailer's raw field, and
float() accepts "Infinity" / "1e999", so a non-finite price reaches here.
The `not original_price` guard catches None and 0, not inf or nan, and the
exception escapes add_products_to_database -> run_monitor_cycle and kills
the whole process -- one bad product takes the monitor down, and it
crashes again on every restart while that product is listed. Every
monitor that uses this lib has the same exposure.

calculate_priority now returns 0 (the existing "broken prices" bucket)
for a non-finite current/original price, and for a finite pair whose
ratio still overflows (a subnormal original price). Tests cover both;
they fail on the old code (OverflowError, plus ValueError for nan) and
pass now (tests/test_alert_priority.py: 10 passed).

Not changed: the monitor-side float() parsing, which could reject these
prices before they are stored -- worth a look by the monitor owners.

Co-Authored-By: Claude Code Opus 5.5 (ansible-ops, auto-repair) <noreply@anthropic.com>
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-08 18:46:59 -04:00
TPV 6720592685 small cleanup of comments/added optional impersonate into run_bounded_cycle for monitors like bhphoto that need different clients than what we support/dont need to rotate 2026-09-29 18:55:52 -07:00
TPV 4563cdf920 updated tls profiles 2026-09-26 21:11:37 -07:00
TPV b9fbb64364 added get_response_json function to return json from aioweb 2026-09-25 20:00:19 -07:00
TPV ae637a134f rename monitor_name parameters to monitor
Applies to setup_monitor_logging, init_monitor, setup_config, get_config,
add_products_to_database and build_alert. Breaking for callers passing
monitor_name by keyword; every monitor repo is updated alongside.
2026-09-25 19:02:09 -07:00
TPV 401258f52a renamed monitor_name to monitor 2026-09-25 17:29:08 -07:00
TPV 9a34ae5ca6 added asins to build_product_details 2026-09-10 06:38:44 -07:00
TPV 0275f94500 added price drops to alerts/fixed race condition where late claimed docs got their tags wiped and sent to the api without tags 2026-09-09 09:52:15 -07:00
TPV 03cc3a35cb added asins for woot listings 2026-09-08 20:14:49 -07:00
TPV 93013c5d55 updated the db name for dsql mongo 2026-08-30 17:59:05 -07:00
11 changed files with 316 additions and 51 deletions

No files matched your search

+2 -1
View File
@@ -5,5 +5,6 @@ __pycache__
.idea
monitor_utils.egg-info
uv.lock
.claude
.claude/
build/
logs/
+4 -4
View File
@@ -29,7 +29,7 @@ def is_production(development_mode: DevelopmentMode) -> bool:
async def get_config(
monitor_name: str,
monitor: str,
use_production: bool = False,
mongo_client: AsyncIOMotorClient = None,
) -> dict:
@@ -39,7 +39,7 @@ async def get_config(
development, so a dev run never reads production routing.
Args:
monitor_name: Monitor identifier (the config document _id).
monitor: Monitor identifier (the config document _id).
use_production: Read the production configs collection instead of
configs_testing.
mongo_client: Client to read through; the shared cluster client when
@@ -58,10 +58,10 @@ async def get_config(
else f"{CONFIGS_COLLECTION_NAME}_testing"
)
config = await get_collection(collection_name, mongo_client=mongo_client).find_one(
{"_id": monitor_name}
{"_id": monitor}
)
if config is None:
raise ConfigNotFoundError(monitor_name)
raise ConfigNotFoundError(monitor)
return config
+10 -10
View File
@@ -15,7 +15,7 @@ from aioweb import AiowebError
from curl_cffi.requests.errors import RequestsError
from pymongo.errors import PyMongoError
MONGO_DATABASE_NAME = "tpv_woot"
MONGO_DATABASE_NAME = "tpv"
MONGO_URL = "mongodb://tpv:mvVuBhhUiCabeNikmUt71L8VqJQHh7IF@ridley.data.dev.ca.rethinkstudios.io:27017/tpv?authSource=admin"
LOGGING_LEVEL = "INFO"
LOGGERS = [
@@ -33,17 +33,17 @@ FAILURE_DELAY = 3
TIMEOUT_SECONDS = 30
CYCLE_TIMEOUT_SECONDS = 600
# curl_cffi's AsyncSession defaults to 10 handles and blocks on an untimed
# pool.get() past that, so a monitor fanning out wider silently serialises with
# no timeout covering the wait. Handles are allocated lazily (the pool holds
# None placeholders until popped), so a generous ceiling costs a low-concurrency
# monitor nothing. Monitors fanning out past this pass max_clients explicitly.
MAX_CURL_HANDLES = 64
# Budget for closing a session. curl_cffi's close awaits the multi handle with
# no timeout of its own, so a wedged transfer would otherwise park whatever is
# closing the session, including an `async with` exit.
SESSION_CLOSE_TIMEOUT_SECONDS = 10
CLIENT_IDENTIFIERS = ["chrome", "chrome_android", "safari", "safari_ios"]
CLIENT_IDENTIFIERS = [
"chrome119",
"chrome120",
"chrome124",
"chrome131",
"chrome131_android",
"safari",
"safari_ios",
]
CLIENT_TIMEOUT = aiohttp.ClientTimeout(
total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30
)
+32 -16
View File
@@ -7,6 +7,7 @@ alert references for new products and price changes.
"""
import logging
import math
from dataclasses import asdict
from datetime import datetime, timezone
from functools import cache
@@ -112,7 +113,7 @@ async def add_products_to_database(
products: list[Product],
collection: AsyncIOMotorCollection,
category_identifier: str,
monitor_name: str,
monitor: str,
use_composite: bool = False,
):
"""Upsert products, tracking deduplicated price history and emitting alerts.
@@ -138,7 +139,7 @@ async def add_products_to_database(
products: Products to upsert.
collection: Target MongoDB collection.
category_identifier: Category suffix appended to composite ids.
monitor_name: Monitor name stamped onto emitted alerts for consumer
monitor: Monitor name stamped onto emitted alerts for consumer
routing.
use_composite: Build composite ids so multi-variant monitors (Woot)
keep one doc per variant; other monitors collapse to one doc per
@@ -180,7 +181,7 @@ async def add_products_to_database(
never_seen_price = new_price is not None and new_price not in recorded_prices
set_on_insert = {"discoveredDate": now}
if existing_doc is None or never_seen_price:
alert = build_alert(monitor_name, document, now)
alert = build_alert(monitor, document, now)
alert_ops.append(
UpdateOne(
{"product_id": doc_id, "claimed": False},
@@ -279,19 +280,22 @@ def extract_recorded_prices(existing_doc: dict | None) -> set[float]:
return {entry["price"] for entry in existing_doc.get("pastPrices", [])}
def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
def build_alert(monitor: str, document: dict, now: datetime) -> dict:
"""Build an alert referencing a product for consumer delivery.
The alert carries its own copy of the document's newListing flag: the
product doc is rewritten every cycle, so its flag flips False one cycle
after discovery, while a discovery alert may sit in a backed-up queue
longer than that. The alert's copy is written once and cannot rot.
The alert carries its own copy of the document's newListing flag and
priceBeforeDrop: the product doc is rewritten every cycle, so its flag
flips False one cycle after discovery and its priceBeforeDrop is
cleared by the next cycle that sees no drop, while an alert may sit in
a backed-up queue longer than that. The alert's copies are written once
and cannot rot.
Args:
monitor_name: Monitor that produced the product, used by the
monitor: Monitor that produced the product, used by the
consumer to resolve the owning collection and thresholds.
document: Product document supplying the id, pricing for the
priority calculation, and the stamped newListing flag.
priority calculation, and the stamped newListing flag and
priceBeforeDrop.
now: Detection timestamp stored as createdAt.
Returns:
@@ -299,13 +303,14 @@ def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
"""
return {
"monitor": monitor_name,
"monitor": monitor,
"product_id": document["_id"],
"priority": calculate_priority(document),
"claimed": False,
"attempts": 0,
"createdAt": now,
"newListing": document.get("newListing", False),
"priceBeforeDrop": document.get("priceBeforeDrop"),
}
@@ -314,10 +319,13 @@ def calculate_priority(document: dict) -> int:
Always recalculated from current and original price so the priority
reflects the prices at alert time rather than any stale stored
percentage. Missing prices, zero original prices, and price increases
resolve to priority 0. A product with no MSRP has no percentage to rank
on and takes NO_MSRP_PRIORITY instead, since a real 0 would leave it
permanently behind every other alert in the claim query's sort. A new
percentage. Missing prices, zero original prices, non-finite prices, a
discount too large to represent, and price increases resolve to priority
0, so one product's bad pricing can never raise out of the whole cycle (a
retailer field like "Infinity" survives float() and used to crash the
monitor with an OverflowError). A product with no MSRP has no percentage
to rank on and takes NO_MSRP_PRIORITY instead, since a real 0 would leave
it permanently behind every other alert in the claim query's sort. A new
listing is floored at NEW_LISTING_PRIORITY for the same reason: it may
carry any discount at all, and its percentage rank still wins when higher.
@@ -333,10 +341,17 @@ def calculate_priority(document: dict) -> int:
if current_price is None or not original_price:
return 0
if not (math.isfinite(current_price) and math.isfinite(original_price)):
return 0
if not document.get("hasMsrp", True):
return NO_MSRP_PRIORITY
percentage = int(((original_price - current_price) / original_price) * 100)
discount = ((original_price - current_price) / original_price) * 100
if not math.isfinite(discount):
return 0
percentage = int(discount)
priority = min(max(percentage // 10, 0), 10)
if document.get("newListing"):
return max(priority, NEW_LISTING_PRIORITY)
@@ -398,6 +413,7 @@ def convert_product_to_mongo(product: Product) -> dict:
"promoPercentage": metadata["promo_percentage"],
"promoDollarOff": metadata["promo_dollar_off"],
"promoMinSpend": metadata["promo_min_spend"],
"asin": metadata["asin"],
"startDate": timing["start_date"],
"endDate": timing["end_date"],
"priceBeforeDrop": pricing["price_before_drop"],
+3 -2
View File
@@ -29,7 +29,7 @@ class ConsumerConfig:
"""Configuration for a single retailer's consumer.
Attributes:
monitor_name: Retailer name (matches setup_config key).
monitor: Retailer name.
collection: MongoDB collection for this retailer's products.
channel_ids: Discord webhook/channel routing config.
min_percentage: Minimum discount % to trigger a ping.
@@ -38,7 +38,7 @@ class ConsumerConfig:
the secondary rule.
"""
monitor_name: str
monitor: str
collection: object
channel_ids: dict
min_percentage: int
@@ -124,6 +124,7 @@ class ProductMetadata:
promo_dollar_off: int | None = None
promo_min_spend: int | None = None
new_listing: bool | None = None
asin: str | None = None
@dataclass
+44 -16
View File
@@ -1,11 +1,13 @@
import asyncio
import logging
import math
from collections.abc import Awaitable, Callable, Iterable
from contextlib import asynccontextmanager, nullcontext
import aiohttp
from aioproxies import AioProxies
from curl_cffi.requests import AsyncSession
from curl_cffi.requests.impersonate import BrowserTypeLiteral
from log_setup import setup_logging
from motor.motor_asyncio import AsyncIOMotorCollection
from motor.motor_asyncio import AsyncIOMotorClient
@@ -37,22 +39,22 @@ from monitor_utils.models import (
LOGGER = logging.getLogger(__name__)
def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
def setup_monitor_logging(monitor: str, logging_level=LOGGING_LEVEL):
"""Configure JSON logging for a monitor and quiet noisy libraries.
Call once at module import, at global scope, before creating module loggers,
so logging is configured synchronously at startup rather than inside the
async init_monitor. Logs go to logs/latest.log with rotated history keyed by
monitor_name.
monitor.
Args:
monitor_name: Monitor name used as the rotated-history base.
monitor: Monitor name used as the rotated-history base.
logging_level: Root logging level.
"""
setup_logging(
name="logs/latest.log",
history_name=monitor_name,
history_name=monitor,
level=logging_level,
output="json",
)
@@ -60,7 +62,7 @@ def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
async def init_monitor(
monitor_name: str,
monitor: str,
development_mode: DevelopmentMode,
production_proxy_path: str = "proxies.txt",
testing_proxy_path: str = "tests/configs/proxies.txt",
@@ -72,7 +74,7 @@ async def init_monitor(
by setup_monitor_logging at module scope and is not touched here.
Args:
monitor_name: Monitor identifier used for config and collection lookup.
monitor: Monitor identifier used for config and collection lookup.
development_mode: Selects production or testing config, collection,
and proxy path.
production_proxy_path: Proxy file path used in production mode.
@@ -84,7 +86,7 @@ async def init_monitor(
use_production = is_production(development_mode)
category_names, _, collection = await setup_config(
monitor_name=monitor_name, development_mode=development_mode
monitor=monitor, development_mode=development_mode
)
proxy_path = production_proxy_path if use_production else testing_proxy_path
proxies = AioProxies.from_file(path=proxy_path)
@@ -92,7 +94,7 @@ async def init_monitor(
async def setup_config(
monitor_name: str,
monitor: str,
development_mode: DevelopmentMode,
mongo_client: AsyncIOMotorClient = None,
) -> tuple[
@@ -105,7 +107,7 @@ async def setup_config(
during development.
Args:
monitor_name: Monitor identifier used for config and collection lookup.
monitor: Monitor identifier used for config and collection lookup.
development_mode: Selects the production or testing config and
collection.
mongo_client: Client the config read and the returned collection use;
@@ -117,14 +119,14 @@ async def setup_config(
use_production = is_production(development_mode)
config = await get_config(
monitor_name=monitor_name,
monitor=monitor,
use_production=use_production,
mongo_client=mongo_client,
)
category_names = config["categories"]
collection = get_collection(
monitor_name if use_production else f"{monitor_name}_testing",
monitor if use_production else f"{monitor}_testing",
mongo_client=mongo_client,
)
return category_names, config["channels"], collection
@@ -159,7 +161,10 @@ def build_product_details(
) -> Product | None:
"""Build a Product from a Mongo document if it passes discount thresholds.
Skips documents with a missing or zero original price. Passing requires
Skips documents with a missing or zero original price, a non-finite
price, or a discount too large to represent: a retailer field like
"Infinity" survives float(), and the percentage math used to raise
OverflowError out of every caller. Passing requires
meeting min_percentage, or min_price_drop when provided. The caller may
bypass both thresholds (new listings send at any discount for monitors
that opt in); which alerts qualify is the caller's decision, not read
@@ -172,7 +177,7 @@ def build_product_details(
min_percentage: Minimum discount percentage to pass.
min_price_drop: Optional minimum absolute price drop to pass.
bypass_thresholds: Build the product even when it fails both
thresholds. The missing-price skip still applies.
thresholds. The missing- and non-finite-price skips still apply.
Returns:
The built Product, or None when skipped or below thresholds.
@@ -190,6 +195,19 @@ def build_product_details(
)
return
# Skipped on BOTH paths, not just the percentage one: an MSRP-less product
# would otherwise carry the infinite price into its payload. Only floats can
# be non-finite, so ints and other types keep their existing behaviour.
if any(
isinstance(price, float) and not math.isfinite(price)
for price in (current_price, original_price)
):
LOGGER.warning(
"non-finite price, skipping",
extra={"product_id": product_id},
)
return
percentage = 0
# Defaulted, not indexed: documents written before the field existed have no
# hasMsrp at all, and stale ones that drop out of a monitor's feed are never
@@ -197,7 +215,15 @@ def build_product_details(
# pre-existing threshold path rather than silently routing them as MSRP-less.
has_msrp = product.get("hasMsrp", True)
if has_msrp:
percentage = int(((original_price - current_price) / original_price) * 100)
discount = ((original_price - current_price) / original_price) * 100
if not math.isfinite(discount):
LOGGER.warning(
"discount not representable, skipping",
extra={"product_id": product_id},
)
return
percentage = int(discount)
price_drop = original_price - current_price
# 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.
@@ -211,7 +237,7 @@ def build_product_details(
if not bypass_thresholds and not passes_percentage and not passes_price_drop:
return
built = Product(
built_product = Product(
product_id=product_id,
title=product["title"],
url=product["url"],
@@ -227,6 +253,7 @@ def build_product_details(
promo_dollar_off=product.get("promoDollarOff"),
promo_min_spend=product.get("promoMinSpend"),
new_listing=new_listing,
asin=product.get("asin"),
),
timing=ProductTiming(
discovered_date=product["discoveredDate"],
@@ -249,7 +276,7 @@ def build_product_details(
member_deal=product["memberDeal"],
),
)
return built
return built_product
async def run_monitor_cycle(
@@ -376,6 +403,7 @@ async def run_bounded_cycle(
session_timeout: int = TIMEOUT_SECONDS,
max_clients: int = MAX_CURL_HANDLES,
cycle_timeout: int = CYCLE_TIMEOUT_SECONDS,
impersonate: BrowserTypeLiteral | None = None,
**kwargs,
) -> CycleResult | None:
"""Run one monitor cycle on a fresh session under a hard time budget.
+47 -1
View File
@@ -13,6 +13,7 @@ import random
from aioproxies import AioProxies
from aioweb import Response
from aioweb_tls import CurlCffi, TLSSession
from curl_cffi.requests.impersonate import BrowserTypeLiteral
from monitor_utils.constants import (
CLIENT_IDENTIFIERS,
MAX_CURL_HANDLES,
@@ -70,6 +71,7 @@ def get_default_session(
headers: dict | None = None,
timeout: int = TIMEOUT_SECONDS,
max_clients: int = MAX_CURL_HANDLES,
impersonate: BrowserTypeLiteral | None = None,
) -> TLSSession:
"""Return a TLSSession impersonating a random client fingerprint.
@@ -98,13 +100,17 @@ def get_default_session(
checkout. Keep it at or above the monitor's real fan-out (the sum
of its pagination semaphores plus any unsemaphored per-cycle
requests) so the wait is never reachable.
impersonate: A curl_cffi profile to pin instead of drawing one from
CLIENT_IDENTIFIERS, for a retailer whose bot protection only
tolerates some profiles from a given IP (B&H's Cloudflare refuses
every chrome profile on a flagged IP but still accepts safari).
Returns:
A BoundedCloseSession (a TLSSession whose close is bounded) over a
curl_cffi backend with a random impersonation, with the proxy pool
attached as proxy_pool.
"""
impersonate = random.choice(CLIENT_IDENTIFIERS)
impersonate = impersonate or random.choice(CLIENT_IDENTIFIERS)
session = BoundedCloseSession(
backend=CurlCffi(impersonate=impersonate),
headers=headers,
@@ -171,3 +177,43 @@ async def send_request(
)
response.raise_for_status()
return response
def get_response_json(
response: Response,
*,
logger: logging.Logger = LOGGER,
expected: type | tuple[type, ...] = dict,
**context,
) -> dict | list | None:
"""Parse a response body as JSON, or log it and return None.
aioweb's response.json() yields None for a non-JSON body, so a single
isinstance check covers both the empty/invalid case and a body of the
wrong shape. Callers stop on None the same way they did with the inline
check this replaces.
Args:
response: A 2xx aioweb Response from send_request.
logger: Logger the warning is emitted on; pass the monitor's own so
the record stays attributed to that monitor.
expected: Type(s) the parsed body must be an instance of.
**context: Extra fields for the warning record (category, page, ...).
Returns:
The parsed body when it matches `expected`, else None.
"""
data = response.json()
if isinstance(data, expected):
return data
logger.warning(
"unexpected response",
extra={
**context,
"body_type": type(data).__name__,
"body": str(data)[:200],
},
)
return None
+17
View File
@@ -50,6 +50,23 @@ def test_broken_prices_still_outrank_nothing():
assert calculate_priority(document(None, 100.0, hasMsrp=False)) == 0
def test_non_finite_prices_resolve_to_zero_instead_of_raising():
"""float("Infinity") from a retailer field once crashed the whole monitor."""
inf, nan = float("inf"), float("nan")
assert calculate_priority(document(50.0, inf)) == 0
assert calculate_priority(document(inf, 100.0)) == 0
assert calculate_priority(document(-inf, 100.0)) == 0
assert calculate_priority(document(nan, 100.0)) == 0
assert calculate_priority(document(50.0, nan)) == 0
assert calculate_priority(document(50.0, inf, newListing=True)) == 0
def test_a_discount_too_large_to_represent_resolves_to_zero():
"""Finite prices can still overflow the ratio (a subnormal original)."""
assert calculate_priority(document(-1e308, 5e-324)) == 0
assert calculate_priority(document(1e308, 5e-324)) == 0
def test_new_listing_is_floored_not_buried():
"""A low-discount new listing sends regardless, so it must not sort last."""
assert (
+49
View File
@@ -78,6 +78,17 @@ def test_promo_fields_read_through():
assert product.metadata.percentage == 20
def test_asin_reads_through():
"""asin round-trips: convert_product_to_mongo writes it, so the consumer
must read it back for the SellerAmp lookup"""
product = build_product_details(_doc(asin="B0CHX3QBCH"), min_percentage=0)
assert product.metadata.asin == "B0CHX3QBCH"
def test_missing_asin_defaults_to_none():
assert build_product_details(_doc(), min_percentage=0).metadata.asin is None
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)
@@ -192,3 +203,41 @@ def test_msrpless_doc_with_prices_builds_unthresholded():
product = build_product_details(doc, min_percentage=40)
assert product is not None
assert product.pricing.has_msrp is False
# ------------------------------------------------------------ non-finite prices
def test_infinite_price_skips_instead_of_raising():
"""Regression (2026-10-09 06:25Z): float("Infinity") from a retailer field
raised OverflowError here and stopped the consumer's API deal loop."""
inf = float("inf")
assert build_product_details(_doc(originalPrice=inf), min_percentage=40) is None
assert build_product_details(_doc(currentPrice=inf), min_percentage=40) is None
assert build_product_details(_doc(currentPrice=-inf), min_percentage=40) is None
def test_nan_price_skips_instead_of_raising():
nan = float("nan")
assert build_product_details(_doc(currentPrice=nan), min_percentage=40) is None
assert build_product_details(_doc(originalPrice=nan), min_percentage=40) is None
def test_msrpless_infinite_price_skips_rather_than_reaching_the_payload():
"""Without the skip the MSRP-less path would carry inf into the Product."""
doc = _doc(hasMsrp=False, currentPrice=float("inf"))
assert build_product_details(doc, min_percentage=40) is None
def test_bypass_does_not_rescue_a_non_finite_price():
doc = _doc(originalPrice=float("inf"))
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
def test_unrepresentable_discount_skips_instead_of_raising():
"""Finite prices can still overflow the ratio (a subnormal original)."""
doc = _doc(currentPrice=-1e308, originalPrice=5e-324)
assert build_product_details(doc, min_percentage=40) is None
+80
View File
@@ -0,0 +1,80 @@
"""unit tests: sessions.get_response_json parses a body or logs and returns None
run from the repo root:
uv run pytest tests/test_response_json.py
offline — the aioweb Response is faked at the leaf; nothing is sent.
"""
import logging
import pytest
from monitor_utils.sessions import get_response_json
class FakeResponse:
def __init__(self, body):
self.body = body
def json(self):
return self.body
def warnings(caplog):
return [r for r in caplog.records if r.levelno == logging.WARNING]
def test_dict_body_is_returned_without_logging(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse({"a": 1})) == {"a": 1}
assert warnings(caplog) == []
def test_none_body_logs_and_returns_none(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse(None), category="c1", page=2) is None
(record,) = warnings(caplog)
assert record.message == "unexpected response"
assert record.body_type == "NoneType"
assert record.body == "None"
assert (record.category, record.page) == ("c1", 2)
@pytest.mark.parametrize("body", [[1, 2], "text", 42, b"bytes"])
def test_wrong_shape_logs_type_and_truncated_body(body, caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse(body)) is None
(record,) = warnings(caplog)
assert record.body_type == type(body).__name__
assert record.body == str(body)[:200]
def test_body_preview_is_capped_at_200_chars(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
get_response_json(FakeResponse("x" * 1000))
(record,) = warnings(caplog)
assert len(record.body) == 200
def test_expected_accepts_other_types(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse([1]), expected=list) == [1]
assert get_response_json(FakeResponse([1]), expected=(dict, list)) == [1]
assert get_response_json(FakeResponse({}), expected=list) is None
assert len(warnings(caplog)) == 1
def test_warning_goes_to_the_passed_logger(caplog):
with caplog.at_level(logging.WARNING):
get_response_json(
FakeResponse(None), logger=logging.getLogger("woot.woot_monitor")
)
(record,) = warnings(caplog)
assert record.name == "woot.woot_monitor"
def test_empty_dict_counts_as_a_dict(caplog):
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
assert get_response_json(FakeResponse({})) == {}
assert warnings(caplog) == []
+28 -1
View File
@@ -99,6 +99,32 @@ def test_existing_doc_with_new_price_stamps_false():
assert alert_doc(collection)["newListing"] is False
def test_drop_alert_carries_price_before_drop():
"""the doc's priceBeforeDrop is cleared by the next no-drop cycle, so the
alert keeps its own copy from emit time — a drop claimed late still knows
what it dropped from"""
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
collection = FakeCollection([existing])
run(collection, [_product(current=95.0)])
assert alert_doc(collection)["priceBeforeDrop"] == 100.0
def test_non_drop_alert_carries_none():
"""a never-seen higher price alerts without a drop; discoveries have no
prior price at all"""
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
collection = FakeCollection([existing])
run(collection, [_product(current=120.0)])
assert alert_doc(collection)["priceBeforeDrop"] is None
discovery = FakeCollection()
run(discovery, [_product()])
assert alert_doc(discovery)["priceBeforeDrop"] is None
def test_existing_doc_with_known_price_emits_no_alert():
existing = {
"_id": "p1",
@@ -112,8 +138,9 @@ def test_existing_doc_with_known_price_emits_no_alert():
assert collection.alerts is None
def test_build_alert_defaults_missing_flag_to_false():
def test_build_alert_defaults_missing_fields():
"""Direct callers passing an unstamped document must not KeyError."""
alert = build_alert("bestbuy", {"_id": "p1"}, NOW)
assert alert["newListing"] is False
assert alert["priceBeforeDrop"] is None