From 1dec62e0e46e2f02a7737318853c756702379d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 21:33:12 +0000 Subject: [PATCH] feat(safety): Tier 1 real-money safety rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the money-moving buy flow with four safety/correctness changes: - Hard cart-contents guard before Place Order: verify the review page holds only the intended item+qty (cart-line count + order-total band) before placing. Fails closed — an unverifiable cart aborts to needs_review rather than risk checking out a stale line left by a best-effort _reset_cart. Runs on dry-run too, so `dry-run` exercises it. Base hook is a no-op; only Costco (shared server-side cart) enforces it. - Tighten Akamai block detection: key on the 403/429 HTTP status as the primary signal and require the deny-page body markers ("access denied" + "reference #") to co-occur, so a benign page mentioning one no longer false-blocks and pauses the worker. - Configurable Costco storeId/catalogId (ROOMIEORDER_COSTCO_STORE_ID / _CATALOG_ID) threaded into the silent SSO re-auth URL, replacing hardcoded ids. - Opt-in bounded auto-retry (ROOMIEORDER_AUTO_RETRY) for money-safe pre-cart transient failures (PDP-load timeout, one-off no_price). A `retryable` flag is set only before any cart interaction; the worker re-enqueues up to a bound instead of pausing. store.py MAX_ATTEMPTS=1 invariant is untouched. Tests cover the cart guard (band, extra line, fail-closed, qty scaling, Amazon no-op), block co-occurrence + status, the worker auto-retry bound, and the new config vars. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J322vufhqKDvdJmukTZdkq --- examples/env.example | 13 +++ src/roomieorder/config.py | 18 +++ src/roomieorder/main.py | 40 +++++++ src/roomieorder/purchase.py | 223 +++++++++++++++++++++++++++++++++--- tests/test_config.py | 12 ++ tests/test_main.py | 57 +++++++++ tests/test_purchase.py | 144 +++++++++++++++++++++-- 7 files changed, 485 insertions(+), 22 deletions(-) diff --git a/examples/env.example b/examples/env.example index f5af881..0ec9209 100644 --- a/examples/env.example +++ b/examples/env.example @@ -36,9 +36,22 @@ ROOMIEORDER_SHOTS_DIR=data/shots # carried, or over its price ceiling. ROOMIEORDER_COSTCO_DOMAIN=costco.com ROOMIEORDER_AMAZON_DOMAIN=amazon.com +# Costco WebSphere warehouse/catalog ids, stamped into the silent SSO re-auth +# URL. The defaults match the account verified live 2026-06-17 — change them only +# if a different warehouse/region bounces the silent re-auth. +ROOMIEORDER_COSTCO_STORE_ID=10301 +ROOMIEORDER_COSTCO_CATALOG_ID=10701 # Pass --ozone-platform=wayland to Chromium (set true on a Wayland session). ROOMIEORDER_WAYLAND=false +# ─────────── Auto-retry (money-safe, opt-in) ─────────── +# Re-drive a *pre-cart* transient failure (network timeout reaching the product +# page, a one-off missed price) instead of pausing for a manual `retry`. Never +# fires once the cart is touched or an order is submitted, so it can't re-order. +# Off by default (behavior unchanged); MAX bounds re-drives per item. +ROOMIEORDER_AUTO_RETRY=false +ROOMIEORDER_AUTO_RETRY_MAX=1 + # ─────────── Browser / anti-bot ─────────── # Akamai fingerprints the real browser build, so drive Google Chrome, not # Playwright's bundled Chromium (which lacks proprietary codecs and brands its diff --git a/src/roomieorder/config.py b/src/roomieorder/config.py index 6f3a5d1..dc35fb9 100644 --- a/src/roomieorder/config.py +++ b/src/roomieorder/config.py @@ -86,6 +86,12 @@ class Config(BaseModel): # Stores costco_domain: str = "costco.com" amazon_domain: str = "amazon.com" + # Costco WebSphere warehouse/catalog ids, stamped into the silent SSO + # re-auth URL (see purchase.CostcoPurchaser.ensure_logged_in). The defaults + # are the values verified live 2026-06-17; a different warehouse/region needs + # its own ids or the silent re-auth bounces. + costco_store_id: str = "10301" + costco_catalog_id: str = "10701" wayland: bool = False # Browser / anti-bot. Akamai fingerprints the *real* browser build, so the @@ -109,6 +115,14 @@ class Config(BaseModel): openclaw_target: str = "" openclaw_channel: str = "telegram" + # Auto-retry for pre-cart transient failures (network timeout reaching the + # PDP, a one-off no_price) — money-safe because the cart is never touched. + # Off by default so behavior is unchanged; the worker bounds re-enqueues per + # item to ``auto_retry_max`` (see main.Engine). Failures at or past the cart + # are never retryable regardless of this flag. + auto_retry: bool = False + auto_retry_max: int = Field(default=1, ge=0) + @property def sheets_enabled(self) -> bool: return bool(self.sheet_id and self.google_service_account_json) @@ -168,6 +182,8 @@ def load_config() -> Config: shots_dir=Path(_env_str("ROOMIEORDER_SHOTS_DIR", "data/shots")), costco_domain=_env_str("ROOMIEORDER_COSTCO_DOMAIN", "costco.com"), amazon_domain=_env_str("ROOMIEORDER_AMAZON_DOMAIN", "amazon.com"), + costco_store_id=_env_str("ROOMIEORDER_COSTCO_STORE_ID", "10301"), + costco_catalog_id=_env_str("ROOMIEORDER_COSTCO_CATALOG_ID", "10701"), wayland=_env_bool("ROOMIEORDER_WAYLAND", False), chrome_path=_env_str("ROOMIEORDER_CHROME_PATH", ""), chrome_channel=_env_str("ROOMIEORDER_CHROME_CHANNEL", "chrome"), @@ -177,4 +193,6 @@ def load_config() -> Config: openclaw_bin=_env_str("OPENCLAW_BIN", "openclaw"), openclaw_target=_env_str("OPENCLAW_TARGET", ""), openclaw_channel=_env_str("OPENCLAW_CHANNEL", "telegram"), + auto_retry=_env_bool("ROOMIEORDER_AUTO_RETRY", False), + auto_retry_max=_env_int("ROOMIEORDER_AUTO_RETRY_MAX", 1), ) diff --git a/src/roomieorder/main.py b/src/roomieorder/main.py index fc7a58f..ad6e013 100644 --- a/src/roomieorder/main.py +++ b/src/roomieorder/main.py @@ -125,6 +125,12 @@ def __init__(self, config: Config) -> None: self.orchestrator = Orchestrator(config, self.store) self._stop = threading.Event() self._thread: Optional[threading.Thread] = None + # Per-item count of consecutive opt-in auto-retries (ROOMIEORDER_AUTO_RETRY), + # bounding how many times a money-safe pre-cart failure is re-driven before + # the worker gives up and pauses. In-memory is fine: the worker is + # single-threaded and these are transient PDP-load failures, so a restart + # resetting the count is harmless (recover_stale covers in-progress rows). + self._transient_attempts: dict[str, int] = {} self._recover_orphans() def _recover_orphans(self) -> None: @@ -199,12 +205,46 @@ def _process(self, row: QueueRow) -> None: self._log_sheet(row, item, result) self.notifier.send(result.message, photo=result.screenshot) + # A money-safe pre-cart failure (no cart interaction, no order placed) can + # be re-driven rather than pausing for manual `retry` — opt-in and bounded. + if result.status == "failed" and self._maybe_auto_retry(row, result): + return + # Any settled, non-auto-retried outcome clears the transient counter so + # the next independent request for this item starts fresh. + self._transient_attempts.pop(row.item_key, None) + if result.status in _PAUSE_STATUSES: self.store.set_paused(True, result.message) _logger.warning("worker paused: %s", result.message) elif result.status == "placed": self._enforce_recorded_cap() + def _maybe_auto_retry(self, row: QueueRow, result: PurchaseResult) -> bool: + """Re-enqueue a money-safe transient failure instead of pausing. + + Only fires when ``ROOMIEORDER_AUTO_RETRY`` is on and the result is flagged + ``retryable`` — set exclusively for failures strictly before any cart + interaction (a PDP-load timeout, a one-off no_price), never once the cart + was touched or an order submitted (see purchase.BasePurchaser.buy). Bounds + re-drives per item to ``auto_retry_max`` so a persistently-failing item + still ends up paused rather than looping. Returns True when it re-enqueued + (caller skips the pause path).""" + if not (self.config.auto_retry and result.retryable): + return False + count = self._transient_attempts.get(row.item_key, 0) + if count >= self.config.auto_retry_max: + return False + self._transient_attempts[row.item_key] = count + 1 + new_id = self.store.enqueue(row.item_key, row.requester) + _logger.info( + "auto-retry %s: transient pre-cart failure (%d/%d) — re-enqueued as #%d", + row.item_key, + count + 1, + self.config.auto_retry_max, + new_id, + ) + return True + def _enforce_recorded_cap(self) -> None: """Backstop the spend cap against *recorded* totals after a placed order. diff --git a/src/roomieorder/purchase.py b/src/roomieorder/purchase.py index 2be280f..b6b22d4 100644 --- a/src/roomieorder/purchase.py +++ b/src/roomieorder/purchase.py @@ -120,6 +120,13 @@ class PurchaseResult: provider: str = "" message: str = "" screenshot: Optional[Path] = None + # True only for a `failed` that happened strictly *before* any cart + # interaction (a transient PDP-load timeout, a one-off no_price) — money-safe + # to re-drive because nothing was added to the cart and no order was placed. + # Never set once the checkout/cart flow has run or an order was submitted, so + # the worker's opt-in auto-retry (main.Engine) can't re-drive a money-adjacent + # failure. See BasePurchaser.buy. + retryable: bool = False @dataclass @@ -318,6 +325,24 @@ def _reset_cart(self, page: "Page") -> None: do nothing.""" return None + def _verify_cart_singleton( + self, + page: "Page", + item_key: str, + item: CatalogItem, + source: SourceT, + price: Optional[float], + review_total: Optional[float], + ) -> Optional[PurchaseResult]: + """Confirm the review page holds only the intended item before placing. + + Returns ``None`` to proceed, or a ``needs_review`` result to abort the + buy *before* Place Order. Base: no-op (``None``) — only a store with a + shared, server-side cart that checks out its whole contents (Costco) + needs to guard against a stale extra line. Amazon's Buy-Now path orders + a single item directly, so it never overrides this.""" + return None + def is_logged_in(self, page: "Page") -> bool: """Best-effort sign-in check via the store's account nav. @@ -451,6 +476,10 @@ def buy( # invite a re-order of an order that may have gone through); they all # route to `needs_review` for a human to confirm. submitted = False + # Flips True the moment we begin driving add-to-cart → checkout. Past + # it a `failed`/`timeout`/`crash` is no longer money-safe to auto-retry + # (the cart may hold our line), so `retryable` stays False from here on. + cart_touched = False # The order total only appears on the *review* page — Costco's # CheckoutConfirmationView_v2 shows just an order number — so it's # read there (before Place Order) and carried down to the result. @@ -459,7 +488,7 @@ def buy( resp = page.goto(url, wait_until="domcontentloaded") http_status = resp.status if resp is not None else None - if self._is_blocked(page): + if self._is_blocked(page, http_status): return self._blocked(page, item_key, "product") if self._is_challenge(page): return self._challenge(page, item_key, "product") @@ -488,8 +517,9 @@ def buy( # cart (base). Best-effort — it navigates away, so reload the PDP # after regardless. self._reset_cart(page) - page.goto(url, wait_until="domcontentloaded") - if self._is_blocked(page): + resp = page.goto(url, wait_until="domcontentloaded") + http_status = resp.status if resp is not None else http_status + if self._is_blocked(page, http_status): return self._blocked(page, item_key, "product") if self._is_challenge(page): return self._challenge(page, item_key, "product") @@ -515,10 +545,13 @@ def buy( price = self._read_price(page) if price is None: shot = self._screenshot(page, item_key, "no_price") + # Pre-cart and money-safe: a missed price often races a slow + # PDP hydration, so let the worker's opt-in auto-retry re-drive. return PurchaseResult( status="failed", message=f"couldn't read a price for {title}", screenshot=shot, + retryable=True, ) decision = proceed_check(price) @@ -532,6 +565,9 @@ def buy( ) # ── reach the review page ── + # From here we drive add-to-cart, so a later failure is no longer + # money-safe to auto-retry. + cart_touched = True if not self._start_checkout(page): # A failure here means we never confirmed the review page. An # Akamai block or a sign-in bounce mid-drive lands here too, so @@ -595,6 +631,20 @@ def buy( self._wait_for_any(page, self.ORDER_TOTAL_SELECTORS, timeout=self._LANDING_TIMEOUT_MS) review_total = self._read_total(page) + # ── hard cart-contents guard (⚠️ real money) ── + # A live Place Order checks out the *whole* cart, and _reset_cart + # is best-effort + silent on failure, so verify the review page + # reflects only our item+qty before placing. Runs on dry-run too, + # so `dry-run` exercises the guard. A mismatch (or an unverifiable + # cart) aborts to needs_review *before* submitted=True — a clean, + # double-order-free stop. Base hook is a no-op; only Costco (shared + # server-side cart) enforces it. + mismatch = self._verify_cart_singleton( + page, item_key, item, source, price, review_total + ) + if mismatch is not None: + return mismatch + # ── DRY_RUN stops here ── if self.config.dry_run: shot = self._screenshot(page, item_key, "review") @@ -691,7 +741,12 @@ def buy( page, item_key, detail, order_total=review_total ) shot = self._screenshot(page, item_key, "timeout") - return PurchaseResult(status="failed", message=detail, screenshot=shot) + return PurchaseResult( + status="failed", + message=detail, + screenshot=shot, + retryable=not cart_touched, + ) except _BUG_EXCEPTIONS: # A programmer error (bad attr/type/name, missing override, …). # Screenshot for context, then re-raise so it can't hide as @@ -707,7 +762,12 @@ def buy( page, item_key, detail, order_total=review_total ) shot = self._screenshot(page, item_key, "crash") - return PurchaseResult(status="failed", message=detail, screenshot=shot) + return PurchaseResult( + status="failed", + message=detail, + screenshot=shot, + retryable=not cart_touched, + ) finally: context.close() @@ -1143,6 +1203,28 @@ def _submitted_unconfirmed( screenshot=shot, ) + def _cart_mismatch( + self, page: "Page", item_key: str, detail: str + ) -> PurchaseResult: + """The review page didn't reflect only our item — abort, never place. + + Reached *before* Place Order, so this is a clean, double-order-free stop: + the cart guard (``_verify_cart_singleton``) saw an extra line, a total + over the expected band, or couldn't verify the cart at all. Returns a + pausing ``needs_review`` so a human drains the cart / confirms before + anything re-orders, with a ``cart_mismatch`` screenshot of the review + page.""" + shot = self._screenshot(page, item_key, "cart_mismatch") + return PurchaseResult( + status="needs_review", + message=( + f"⚠️ {self.STORE_NAME}: cart contents didn't verify as the single " + f"intended item — NOT placed; check the cart before re-ordering " + f"({detail})" + ), + screenshot=shot, + ) + def _read_total(self, page: "Page") -> Optional[float]: """First parseable amount from ``ORDER_TOTAL_SELECTORS``, or None. @@ -1234,13 +1316,34 @@ def _settle(self, page: "Page") -> None: except Exception: # noqa: BLE001 — bounded wait; shoot what painted pass - def _is_blocked(self, page: "Page") -> bool: + # HTTP statuses that are an Akamai hard block on their own — a 403 deny page + # or a 429 rate-limit — regardless of body text. The drift-proof primary + # signal; body markers only corroborate. + _BLOCK_STATUS_CODES = (403, 429) + + def _is_blocked(self, page: "Page", http_status: Optional[int] = None) -> bool: + """True on an Akamai hard block: a 403/429 response, or the deny page's + co-occurring body markers. + + The HTTP status is the reliable signal (the Access Denied and rate-limit + pages both return 403/429), so it's checked first. Body markers in + ``BLOCK_MARKERS`` must *all* co-occur — the real Akamai deny page carries + both "Access Denied" and a "Reference #" — so a benign page mentioning + just one (an order's "Reference #", a help article naming Akamai) no + longer false-blocks and needlessly pauses the worker. ``http_status`` is + only known at the initial product navigation; later checkout/confirm + calls pass ``None`` and rely on the co-occurring markers.""" + if http_status in self._BLOCK_STATUS_CODES: + return True + if not self.BLOCK_MARKERS: + return False try: text = page.locator("body").inner_text(timeout=3_000) url = page.url except Exception: # noqa: BLE001 return False - return looks_like(text, url, self.BLOCK_MARKERS) + haystack = f"{text}\n{url}".lower() + return all(marker in haystack for marker in self.BLOCK_MARKERS) def _is_challenge(self, page: "Page") -> bool: try: @@ -1391,6 +1494,18 @@ class CostcoPurchaser(BasePurchaser[CostcoSource]): ".order-total .value", ".grand-total .value", ) + # Per-line rows on the SinglePageCheckoutView review page — the cart-singleton + # guard counts these to confirm only our item is being checked out. The legacy + # WebSphere checkout renders order lines with automation-id/order-item markup; + # these are best guesses. TODO(costco): verify against live DOM via dump-dom. + # Unverified-and-absent is handled safely: the guard falls back to the + # (live-verified) order-total band and, failing that, fails closed. + CART_LINE_SELECTORS = ( + "[automation-id^='orderItemLine_']", + "[automation-id^='lineItem_']", + ".order-item", + ".line-item-detail", + ) # Legacy logon-form submit control. No longer used by the buy flow: Costco's # re-auth is a silent SSO redirect (see ensure_logged_in), not a typed form, # so there's no submit button to click. Kept only for the dump-dom probe. @@ -1409,14 +1524,18 @@ class CostcoPurchaser(BasePurchaser[CostcoSource]): "[data-testid='icon-links-member-links-desktop-account']", "[data-testid='search-strip-member-links-desktop-account']", ) - # Akamai *hard block* — a 403 "Access Denied" deny page (fingerprint/IP ban). - # Nothing to solve in the browser, so it pauses as `blocked` (not `challenge`) - # with a "wait it out / rotate" message. Kept disjoint from CHALLENGE_MARKERS - # and checked first. TODO(costco): verify against live DOM. + # Akamai *hard block* body signature — a 403 "Access Denied" deny page + # (fingerprint/IP ban). ALL markers must co-occur (see _is_blocked): the real + # deny page carries both "Access Denied" and a "Reference #", so requiring + # both stops a benign page that merely mentions one (an order's "Reference #") + # from false-blocking. The 403/429 status is the primary signal; this body + # check only corroborates when the status isn't known. Nothing to solve in + # the browser, so it pauses as `blocked` (not `challenge`) with a "wait it out + # / rotate" message. Kept disjoint from CHALLENGE_MARKERS. Verified live + # 2026-06-17 (Access Denied + Reference # co-occur on the deny page). BLOCK_MARKERS = ( "access denied", "reference #", - "akamai", ) # Costco/Akamai *interactive* bot wall — a captcha/verification a human can # solve, so it pauses as `challenge`. TODO(costco): verify against live DOM. @@ -1526,7 +1645,9 @@ def ensure_logged_in(self, page: "Page") -> bool: return True try: page.goto( - f"https://www.{self.domain}/LogonForm?langId=-1&storeId=10301&catalogId=10701", + f"https://www.{self.domain}/LogonForm?langId=-1" + f"&storeId={self.config.costco_store_id}" + f"&catalogId={self.config.costco_catalog_id}", wait_until="domcontentloaded", ) except Exception: # noqa: BLE001 — couldn't reach the logon flow; give up @@ -1668,6 +1789,82 @@ def _confirm_if_visible(self, page: "Page", selectors: tuple[str, ...]) -> None: except Exception: # noqa: BLE001 — not shown / not clickable; skip continue + # Cart-singleton guard tuning. The review-page grand total carries tax, + # shipping and fees on top of the item subtotal, so the band allows the + # subtotal (price × qty) to grow by ``_CART_TOTAL_BAND_MULT`` plus a flat + # ``_CART_TOTAL_MARGIN`` (covers a small order whose fixed shipping dwarfs a + # cheap item). A total above that band means the cart holds more than our + # line. Deliberately generous: a false abort is a safe needs_review, but a + # missed extra line is a wrong real order. + _CART_TOTAL_BAND_MULT = 1.30 + _CART_TOTAL_MARGIN = 15.0 + + def _verify_cart_singleton( + self, + page: "Page", + item_key: str, + item: CatalogItem, + source: CostcoSource, + price: Optional[float], + review_total: Optional[float], + ) -> Optional[PurchaseResult]: + """Confirm the review page checks out only our item before Place Order. + + Two signals: the number of line rows on the review page, and the order + total against an expected band. A positive mismatch (extra line, or a + total over band) aborts; a positive single-line read or an in-band total + verifies. When *neither* signal is readable the cart can't be verified, + so it fails closed (aborts) rather than risk checking out a stale line. + """ + qty = item.qty + line_count = self._count_cart_lines(page) + + # More lines than the single item we added → an extra line rides along. + if line_count is not None and line_count > 1: + return self._cart_mismatch( + page, item_key, f"{line_count} lines on the review page, expected 1" + ) + + # Order-total band — the reliable, live-verified signal. + total_ok = False + if price is not None and review_total is not None: + ceiling = price * qty * self._CART_TOTAL_BAND_MULT + self._CART_TOTAL_MARGIN + if review_total > ceiling: + return self._cart_mismatch( + page, + item_key, + f"order total ${review_total:.2f} exceeds the ${ceiling:.2f} " + f"band for {qty}×${price:.2f}", + ) + total_ok = True + + # Verified if the total is in band or we positively counted one line. + if total_ok or line_count == 1: + return None + + # Couldn't read either signal → fail closed (operator decision). + return self._cart_mismatch( + page, + item_key, + "couldn't read the cart line count or order total to verify a single item", + ) + + def _count_cart_lines(self, page: "Page") -> Optional[int]: + """Count line rows on the review page, or None if no selector resolves. + + Returns the match count of the first ``CART_LINE_SELECTORS`` candidate + that resolves to one or more rows; ``None`` when every guess misses (so + the guard relies on the total band / fails closed rather than reading an + absent selector as an empty cart).""" + for sel in self.CART_LINE_SELECTORS: + try: + count = page.locator(sel).count() + except Exception: # noqa: BLE001 — try the next candidate + continue + if count > 0: + return count + return None + class AmazonPurchaser(BasePurchaser[AmazonSource]): """Amazon — the fallback when Costco can't fulfil an item. diff --git a/tests/test_config.py b/tests/test_config.py index c1846e9..ee1105a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,10 @@ def test_defaults_when_env_empty(monkeypatch: pytest.MonkeyPatch) -> None: assert cfg.port == 8723 assert cfg.costco_domain == "costco.com" assert cfg.amazon_domain == "amazon.com" + assert cfg.costco_store_id == "10301" + assert cfg.costco_catalog_id == "10701" + assert cfg.auto_retry is False + assert cfg.auto_retry_max == 1 assert cfg.sheets_enabled is False assert cfg.notify_enabled is False @@ -25,11 +29,19 @@ def test_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ROOMIEORDER_PORT", "9000") monkeypatch.setenv("ROOMIEORDER_DAILY_CAP", "50.5") monkeypatch.setenv("OPENCLAW_TARGET", "-100200300") + monkeypatch.setenv("ROOMIEORDER_COSTCO_STORE_ID", "847") + monkeypatch.setenv("ROOMIEORDER_COSTCO_CATALOG_ID", "11005") + monkeypatch.setenv("ROOMIEORDER_AUTO_RETRY", "true") + monkeypatch.setenv("ROOMIEORDER_AUTO_RETRY_MAX", "3") cfg = load_config() assert cfg.dry_run is False assert cfg.port == 9000 assert cfg.daily_cap == 50.5 assert cfg.notify_enabled is True + assert cfg.costco_store_id == "847" + assert cfg.costco_catalog_id == "11005" + assert cfg.auto_retry is True + assert cfg.auto_retry_max == 3 def test_bad_number_raises(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_main.py b/tests/test_main.py index 2187821..a705e9d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -284,6 +284,63 @@ def append(self, row: dict[str, object]) -> bool: engine.store.close() +def test_auto_retry_reenqueues_money_safe_failures_bounded(config: Config) -> None: + # A retryable pre-cart failure is re-driven instead of pausing, bounded to + # auto_retry_max so a persistently-failing item still ends up stopping. + from datetime import datetime, timezone + + from roomieorder.main import Engine + from roomieorder.store import QueueRow + + cfg = config.model_copy(update={"auto_retry": True, "auto_retry_max": 2}) + engine = Engine(cfg) + try: + now = datetime.now(timezone.utc) + row = QueueRow( + id=1, item_key="paper_towels", requester="bob", status="failed", + created_at=now, updated_at=now, + ) + result = PurchaseResult(status="failed", retryable=True, message="timeout") + + assert engine._maybe_auto_retry(row, result) is True + assert engine._maybe_auto_retry(row, result) is True + # Bound reached → no further retry; the caller falls through to pausing. + assert engine._maybe_auto_retry(row, result) is False + + pending = [r for r in engine.store.list_queue(50) if r.status == "pending"] + assert len(pending) == 2 + assert all(r.item_key == "paper_towels" for r in pending) + finally: + engine.store.close() + + +def test_auto_retry_off_by_default_and_skips_non_retryable(config: Config) -> None: + from datetime import datetime, timezone + + from roomieorder.main import Engine + from roomieorder.store import QueueRow + + now = datetime.now(timezone.utc) + row = QueueRow( + id=1, item_key="paper_towels", requester="bob", status="failed", + created_at=now, updated_at=now, + ) + + # Flag off (default) → never retries, even a retryable failure. + off = Engine(config) + try: + assert off._maybe_auto_retry(row, PurchaseResult(status="failed", retryable=True)) is False + finally: + off.store.close() + + # Flag on but the failure isn't money-safe (cart touched) → still no retry. + on = Engine(config.model_copy(update={"auto_retry": True})) + try: + assert on._maybe_auto_retry(row, PurchaseResult(status="failed", retryable=False)) is False + finally: + on.store.close() + + def test_worker_pauses_on_challenge(config: Config, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("roomieorder.main._WORKER_POLL_SECONDS", 0.02) monkeypatch.setattr("roomieorder.main.Orchestrator", FakeOrchestrator) diff --git a/tests/test_purchase.py b/tests/test_purchase.py index ef26239..101bfc4 100644 --- a/tests/test_purchase.py +++ b/tests/test_purchase.py @@ -6,6 +6,7 @@ import pytest +from roomieorder.catalog import load_catalog from roomieorder.config import Config from roomieorder.purchase import ( _JSONLD_SELECTOR, @@ -105,19 +106,35 @@ def test_looks_like_challenge(text: str, url: str, expected: bool) -> None: @pytest.mark.parametrize( - "text,url,expected", + "body,expected", [ - ("Access Denied", "", True), - ("Reference #18.abc1234.1700000000.deadbeef", "", True), - ("blocked by AkamAI edge", "", True), + # The real Akamai deny page carries BOTH markers — only their co-occurrence + # is a hard block now (tightened to stop benign single-marker false blocks). + ("Access Denied\nReference #18.abc1234.1700000000.deadbeef", True), + # A lone marker is no longer a block: an order/help page mentioning a + # "Reference #", or "Access Denied" copy without the Akamai reference id. + ("Reference #18.abc1234.1700000000.deadbeef", False), + ("Access Denied — you can't view this member-only deal", False), + ("blocked by AkamAI edge", False), # Interactive walls are `challenge`, not a hard block. - ("Please verify you are human", "", False), - ("Pardon Our Interruption", "", False), - ("normal product page", "https://www.costco.com/x.product.123.html", False), + ("Please verify you are human", False), + ("Pardon Our Interruption", False), + ("normal product page", False), ], ) -def test_looks_like_blocked(text: str, url: str, expected: bool) -> None: - assert looks_like(text, url, CostcoPurchaser.BLOCK_MARKERS) is expected +def test_is_blocked_requires_marker_cooccurrence( + config: Config, body: str, expected: bool +) -> None: + purchaser = _purchaser(config) + assert purchaser._is_blocked(_BodyPage(body)) is expected + + +@pytest.mark.parametrize("status,expected", [(403, True), (429, True), (200, False), (404, False), (None, False)]) +def test_is_blocked_keys_on_http_status(config: Config, status: int | None, expected: bool) -> None: + # A 403/429 is the primary, drift-proof block signal — true even when the body + # carries no deny markers (an Akamai deny page that didn't paint its text yet). + purchaser = _purchaser(config) + assert purchaser._is_blocked(_BodyPage("nothing to see"), status) is expected def test_block_and_challenge_markers_are_disjoint() -> None: @@ -642,6 +659,115 @@ def test_reset_cart_is_base_noop_for_amazon(config: Config) -> None: _amazon(config)._reset_cart(object()) +# ─────────── cart-singleton guard (verify only our item before placing) ─────────── + + +class _ReviewLineLocator: + def __init__(self, page: "_ReviewPage", selector: str) -> None: + self._page = page + self._selector = selector + + @property + def first(self) -> "_ReviewLineLocator": + return self + + def count(self) -> int: + # Only the first CART_LINE_SELECTORS candidate resolves; ``lines=None`` + # models every candidate missing (selector drift). + if self._page.lines is None: + return 0 + return self._page.lines if self._selector == CostcoPurchaser.CART_LINE_SELECTORS[0] else 0 + + +class _ReviewPage: + """Review page exposing a fixed cart-line count for _verify_cart_singleton.""" + + def __init__(self, lines: int | None) -> None: + self.lines = lines + self.url = "https://www.costco.com/SinglePageCheckoutView" + + def locator(self, selector: str) -> _ReviewLineLocator: + return _ReviewLineLocator(self, selector) + + def screenshot(self, path: str | None = None, full_page: bool = False) -> None: + return None + + +def _costco_item(config: Config, key: str = "paper_towels"): # type: ignore[no-untyped-def] + items = load_catalog(config.catalog_path) + item = items[key] + return item, item.costco + + +def test_cart_guard_passes_single_line_in_band(config: Config) -> None: + # One line on the review page and a total within the tax/shipping band → place. + item, source = _costco_item(config) # qty 1, expected $24.99 + res = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=1), "paper_towels", item, source, 24.99, 27.50 + ) + assert res is None + + +def test_cart_guard_aborts_on_extra_line(config: Config) -> None: + # A stale extra line _reset_cart didn't drain → never place; needs_review. + item, source = _costco_item(config) + res = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=2), "paper_towels", item, source, 24.99, 49.98 + ) + assert res is not None and res.status == "needs_review" + + +def test_cart_guard_aborts_on_total_over_band(config: Config) -> None: + # Even with a single readable line, a total far over the band (an extra line + # the line selector didn't see) aborts. ceiling = 24.99*1.30 + 15 = $47.49. + item, source = _costco_item(config) + res = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=1), "paper_towels", item, source, 24.99, 60.00 + ) + assert res is not None and res.status == "needs_review" + + +def test_cart_guard_fails_closed_when_unverifiable(config: Config) -> None: + # Neither a line count nor a total is readable → can't verify → fail closed. + item, source = _costco_item(config) + res = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=None), "paper_towels", item, source, 24.99, None + ) + assert res is not None and res.status == "needs_review" + + +def test_cart_guard_passes_in_band_without_line_selectors(config: Config) -> None: + # Line selectors miss, but the (live-verified) total is in band → verified. + item, source = _costco_item(config) + res = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=None), "paper_towels", item, source, 24.99, 30.00 + ) + assert res is None + + +def test_cart_guard_band_scales_with_qty(config: Config) -> None: + # dish_soap qty=2 @ $11.99 → subtotal $23.98, ceiling 23.98*1.30+15 ≈ $46.17. + item, source = _costco_item(config, "dish_soap") + ok = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=1), "dish_soap", item, source, 11.99, 26.00 + ) + assert ok is None + over = _purchaser(config)._verify_cart_singleton( + _ReviewPage(lines=1), "dish_soap", item, source, 11.99, 80.00 + ) + assert over is not None and over.status == "needs_review" + + +def test_cart_guard_base_hook_is_noop_for_amazon(config: Config) -> None: + # Amazon's Buy-Now path never touches the shared cart, so the base hook is a + # no-op (None) regardless of the totals — it must never abort the fallback. + item, source = _costco_item(config) + assert ( + _amazon(config)._verify_cart_singleton(object(), "x", item, source, 99.0, 999.0) + is None + ) + + # ─────────── Amazon checkout (the fallback flow) ───────────