From bf1f3ea182352b6fab10b4c2de82d8600399fa8d Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:36:38 +0000
Subject: [PATCH] feat(cart): add a local Redefine cart filler (#220)
* feat(cart): add a local Redefine cart filler
Match the admin order-list CSV to the live menu and add each meal to a guest cart so the weekly bulk order does not have to be typed in by hand.
* fix(cart): report cart failures without a traceback
A closed window or a failed cart request during the fill now prints a short failure and the Added/Failed/Skipped summary instead of crashing.
* fix(cart): fail cleanly on a bad CSV and a closed browser
A non-UTF-8 order list and a Playwright error while opening the page now print a short message and exit 1 instead of a traceback.
* fix(cart): account for a closed window and an empty menu
A closed page during add marks the remaining meals as not attempted, and an empty menu catalog raises the same error as the menu parser.
---
scripts/fill_redefine_cart.py | 271 ++++++++++++++++++++++++++++++++++
src/scraper/fill_cart.py | 203 +++++++++++++++++++++++++
src/scraper/parse_menu.py | 18 ++-
tests/test_fill_cart.py | 265 +++++++++++++++++++++++++++++++++
tests/test_parse_menu.py | 8 +-
5 files changed, 762 insertions(+), 3 deletions(-)
create mode 100644 scripts/fill_redefine_cart.py
create mode 100644 src/scraper/fill_cart.py
create mode 100644 tests/test_fill_cart.py
diff --git a/scripts/fill_redefine_cart.py b/scripts/fill_redefine_cart.py
new file mode 100644
index 0000000..3ff81f3
--- /dev/null
+++ b/scripts/fill_redefine_cart.py
@@ -0,0 +1,271 @@
+"""Fill a Redefine Meals guest cart from an admin order-list CSV.
+
+Opens a visible browser, adds each matched meal, then stops on the cart page.
+Log in and check out in that window. Press Enter when finished, or close the
+window. The script does not store a password and does not open checkout itself.
+
+ python3 scripts/fill_redefine_cart.py order-list-2026-W39.csv
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+import threading
+from pathlib import Path
+from urllib.parse import urlsplit
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from playwright.sync_api import Error as PlaywrightError # noqa: E402
+from playwright.sync_api import sync_playwright # noqa: E402
+
+from scraper.fill_cart import ( # noqa: E402
+ FillCartError,
+ FillPlan,
+ build_plan,
+ cart_lines,
+ error_text,
+ quantity_for,
+)
+from scraper.parse_menu import ( # noqa: E402
+ MenuParseError,
+ extract_catalog_products,
+ fetch_menu_html,
+)
+
+DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
+_CART_JS = """
+async ({ method, path, body }) => {
+ const headers = {
+ Accept: "application/json",
+ "X-Requested-With": "XMLHttpRequest",
+ };
+ const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
+ if (match) {
+ headers["X-XSRF-TOKEN"] = decodeURIComponent(match[1]);
+ }
+ const init = { method, credentials: "same-origin", headers };
+ if (body !== null && body !== undefined) {
+ headers["Content-Type"] = "application/json";
+ init.body = JSON.stringify(body);
+ }
+ const response = await fetch(path, init);
+ const text = await response.text();
+ let data = null;
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch (error) {
+ data = { message: text.slice(0, 200) };
+ }
+ }
+ return { ok: response.ok, status: response.status, data };
+}
+"""
+
+
+def main(argv: list[str] | None = None) -> int:
+ sys.stdout.reconfigure(line_buffering=True)
+ sys.stderr.reconfigure(line_buffering=True)
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("csv_path", type=Path, help="Admin order-list CSV")
+ parser.add_argument("--menu-url", default=DEFAULT_MENU_URL)
+ args = parser.parse_args(argv)
+
+ try:
+ csv_text = args.csv_path.read_text(encoding="utf-8-sig")
+ menu_html = fetch_menu_html(args.menu_url)
+ products = extract_catalog_products(menu_html)
+ plan = build_plan(csv_text, products)
+ except UnicodeDecodeError:
+ print("CSV must be UTF-8.", file=sys.stderr)
+ return 1
+ except (OSError, FillCartError, MenuParseError) as exc:
+ print(exc, file=sys.stderr)
+ return 1
+
+ _print_plan(plan)
+ if not plan.adds:
+ print("Nothing matched. Not opening a browser.")
+ return 1 if plan.skipped else 0
+
+ return _fill(plan, args.menu_url)
+
+
+def _fill(plan: FillPlan, menu_url: str) -> int:
+ with sync_playwright() as playwright:
+ try:
+ browser = playwright.chromium.launch(headless=False)
+ except PlaywrightError as exc:
+ print(f"Browser failed: {exc}", file=sys.stderr)
+ print(
+ "Install the browser with: playwright install chromium",
+ file=sys.stderr,
+ )
+ return 1
+ return _fill_browser(browser, plan, menu_url)
+
+
+def _fill_browser(browser, plan: FillPlan, menu_url: str) -> int:
+ try:
+ try:
+ page = browser.new_page()
+ except PlaywrightError as exc:
+ print(f"Failed: {exc}")
+ print(f"Added 0. Failed 1. Skipped {len(plan.skipped)}.")
+ return 1
+ return _fill_page(page, plan, menu_url)
+ finally:
+ try:
+ browser.close()
+ except PlaywrightError:
+ pass
+
+
+def _fill_page(page, plan: FillPlan, menu_url: str) -> int:
+ failed: list[str] = []
+ added = 0
+ ready = False
+ try:
+ page.goto(menu_url, wait_until="domcontentloaded", timeout=60000)
+ cart = _cart_request(page, "GET", "/api/cart")
+ existing = cart_lines(cart)
+ if existing:
+ print("The cart already has items:")
+ for line in existing:
+ print(f" {line.name} x{line.quantity}")
+ if not _confirm_clear():
+ print("Exiting without adding.")
+ return 1
+ cleared = _cart_request(page, "POST", "/api/cart/clear", {})
+ if cart_lines(cleared):
+ print("The cart still has items after clear. Exiting without adding.")
+ return 1
+
+ posted = []
+ page_closed = False
+ for index, item in enumerate(plan.adds):
+ try:
+ _cart_request(
+ page,
+ "POST",
+ "/api/cart/add",
+ {
+ "uuid": item.uuid,
+ "quantity": item.quantity,
+ "properties": None,
+ },
+ )
+ except RuntimeError as exc:
+ failed.append(f"{item.name}: {exc}")
+ print(f"Failed: {item.name}: {exc}")
+ continue
+ except PlaywrightError as exc:
+ failed.append(f"{item.name}: {exc}")
+ print(f"Failed: {item.name}: {exc}")
+ for rest in plan.adds[index + 1 :]:
+ message = f"{rest.name}: not attempted"
+ failed.append(message)
+ print(f"Failed: {message}")
+ page_closed = True
+ break
+ posted.append(item)
+
+ if not page_closed:
+ try:
+ current = cart_lines(_cart_request(page, "GET", "/api/cart"))
+ except RuntimeError as exc:
+ failed.append(f"Could not read the cart: {exc}")
+ print(f"Failed: Could not read the cart: {exc}")
+ else:
+ for item in posted:
+ found = quantity_for(current, item.uuid)
+ if found != item.quantity:
+ message = (
+ f"{item.name}: requested {item.quantity}, cart has {found}"
+ )
+ failed.append(message)
+ print(f"Failed: {message}")
+ else:
+ added += 1
+ origin = _origin(page.url or menu_url)
+ page.goto(f"{origin}/cart", wait_until="domcontentloaded", timeout=60000)
+ ready = True
+ except (RuntimeError, PlaywrightError) as exc:
+ failed.append(str(exc))
+ print(f"Failed: {exc}")
+ print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.")
+ if ready:
+ _wait_for_review(page)
+ return 1 if failed or plan.skipped else 0
+
+
+def _cart_request(page, method: str, path: str, body: object | None = None) -> object:
+ result = page.evaluate(_CART_JS, {"method": method, "path": path, "body": body})
+ if not isinstance(result, dict) or not result.get("ok"):
+ status = result.get("status") if isinstance(result, dict) else 0
+ data = result.get("data") if isinstance(result, dict) else None
+ raise RuntimeError(error_text(int(status or 0), data))
+ return result.get("data")
+
+
+def _confirm_clear() -> bool:
+ if not sys.stdin.isatty():
+ print("Refusing to clear a cart without a typed yes.")
+ return False
+ answer = input("Type yes to clear the cart and add this order: ")
+ return answer.strip().casefold() == "yes"
+
+
+def _wait_for_review(page) -> None:
+ print(
+ "Cart is ready. Log in and check out in this browser window.\n"
+ "Press Enter here after you are done. Closing the window also stops the script."
+ )
+ finished = threading.Event()
+
+ def _wait_for_enter() -> None:
+ try:
+ input()
+ except EOFError:
+ pass
+ finished.set()
+
+ if sys.stdin.isatty():
+ threading.Thread(target=_wait_for_enter, daemon=True).start()
+
+ while not finished.is_set():
+ try:
+ closed = page.is_closed()
+ except PlaywrightError:
+ return
+ if closed:
+ return
+ try:
+ page.wait_for_timeout(250)
+ except PlaywrightError:
+ return
+
+
+def _print_plan(plan: FillPlan) -> None:
+ if plan.ignored_zero:
+ print(f"Ignored {plan.ignored_zero} items with quantity 0.")
+ if plan.adds:
+ print("Adding:")
+ for item in plan.adds:
+ print(f" {item.name} x{item.quantity}")
+ if plan.skipped:
+ print("Skipped:")
+ for line in plan.skipped:
+ print(f" {line.name}: {line.reason}")
+
+
+def _origin(url: str) -> str:
+ parts = urlsplit(url)
+ return f"{parts.scheme}://{parts.netloc}"
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/scraper/fill_cart.py b/src/scraper/fill_cart.py
new file mode 100644
index 0000000..042aaff
--- /dev/null
+++ b/src/scraper/fill_cart.py
@@ -0,0 +1,203 @@
+"""Match an admin order-list CSV to the Redefine menu catalog.
+
+The browser script uses this plan. Nothing here contacts the site.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+from dataclasses import dataclass
+
+_FORMULA_PREFIX = set("=+-@\t\r")
+
+
+class FillCartError(ValueError):
+ """The order-list CSV cannot be planned."""
+
+
+@dataclass(frozen=True)
+class OrderLine:
+ name: str
+ quantity: int
+
+
+@dataclass(frozen=True)
+class PlannedAdd:
+ name: str
+ quantity: int
+ uuid: str
+
+
+@dataclass(frozen=True)
+class SkippedLine:
+ name: str
+ reason: str
+
+
+@dataclass(frozen=True)
+class FillPlan:
+ adds: tuple[PlannedAdd, ...]
+ skipped: tuple[SkippedLine, ...]
+ ignored_zero: int
+
+
+@dataclass(frozen=True)
+class CartLine:
+ name: str
+ quantity: int
+ product_uuid: str | None
+
+
+def build_plan(csv_text: str, products: list) -> FillPlan:
+ parsed = _parse_order_list(csv_text)
+ adds, skipped = _match(parsed.lines, products)
+ return FillPlan(
+ adds=tuple(adds),
+ skipped=parsed.skipped + tuple(skipped),
+ ignored_zero=parsed.ignored_zero,
+ )
+
+
+def cart_lines(cart: object) -> list[CartLine]:
+ if not isinstance(cart, dict):
+ return []
+ items = cart.get("items")
+ if not isinstance(items, list):
+ return []
+ lines = []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ product = item.get("product") if isinstance(item.get("product"), dict) else {}
+ name = str(product.get("name") or item.get("name") or "Unknown item").strip()
+ product_uuid = product.get("uuid") or item.get("uuid")
+ lines.append(
+ CartLine(
+ name=name or "Unknown item",
+ quantity=_as_int(item.get("quantity")),
+ product_uuid=str(product_uuid) if product_uuid else None,
+ )
+ )
+ return lines
+
+
+def quantity_for(lines: list[CartLine], product_uuid: str) -> int:
+ return sum(line.quantity for line in lines if line.product_uuid == product_uuid)
+
+
+def error_text(status: int, data: object) -> str:
+ """Short cart-API failure text. Vendor bodies can include stack traces."""
+ message = ""
+ if isinstance(data, dict):
+ raw = data.get("message")
+ if isinstance(raw, str):
+ message = " ".join(raw.split())
+ if message:
+ return f"HTTP {status}: {message[:200]}"
+ return f"HTTP {status}"
+
+
+@dataclass(frozen=True)
+class _ParsedCsv:
+ lines: tuple[OrderLine, ...]
+ skipped: tuple[SkippedLine, ...]
+ ignored_zero: int
+
+
+def _parse_order_list(csv_text: str) -> _ParsedCsv:
+ text = csv_text.lstrip("\ufeff")
+ if not text.strip():
+ raise FillCartError("CSV is empty")
+
+ reader = csv.DictReader(io.StringIO(text))
+ if reader.fieldnames is None:
+ raise FillCartError("CSV must have Item and Quantity columns")
+ fields = {(name or "").strip().casefold(): name for name in reader.fieldnames}
+ if "item" not in fields or "quantity" not in fields:
+ raise FillCartError("CSV must have Item and Quantity columns")
+
+ totals: dict[str, int] = {}
+ display: dict[str, str] = {}
+ order: list[str] = []
+ skipped: list[SkippedLine] = []
+ ignored_zero = 0
+
+ for row in reader:
+ raw_name = row.get(fields["item"]) or ""
+ name = _unescape_item(str(raw_name).strip())
+ raw_qty = str(row.get(fields["quantity"]) or "").strip()
+ if not name and not raw_qty:
+ continue
+ if not name:
+ skipped.append(SkippedLine("(blank)", "missing a name"))
+ continue
+ if not raw_qty.isdigit():
+ skipped.append(SkippedLine(name, f"invalid quantity {raw_qty!r}"))
+ continue
+ quantity = int(raw_qty)
+ if quantity == 0:
+ ignored_zero += 1
+ continue
+ key = name.casefold()
+ if key not in totals:
+ order.append(key)
+ display[key] = name
+ totals[key] = 0
+ totals[key] += quantity
+
+ lines = tuple(OrderLine(display[key], totals[key]) for key in order)
+ return _ParsedCsv(lines=lines, skipped=tuple(skipped), ignored_zero=ignored_zero)
+
+
+def _unescape_item(name: str) -> str:
+ if len(name) > 1 and name[0] == "'" and name[1] in _FORMULA_PREFIX:
+ return name[1:]
+ return name
+
+
+def _match(
+ lines: tuple[OrderLine, ...], products: list
+) -> tuple[list[PlannedAdd], list[SkippedLine]]:
+ available: dict[str, list[tuple[str, str]]] = {}
+ unavailable: set[str] = set()
+ for product in products:
+ if not isinstance(product, dict):
+ continue
+ name = str(product.get("name") or "").strip()
+ if not name:
+ continue
+ key = name.casefold()
+ if product.get("available") is False:
+ unavailable.add(key)
+ continue
+ uuid = str(product.get("uuid") or "").strip()
+ if not uuid:
+ continue
+ available.setdefault(key, []).append((name, uuid))
+
+ adds: list[PlannedAdd] = []
+ skipped: list[SkippedLine] = []
+ for line in lines:
+ key = line.name.casefold()
+ matches = available.get(key, [])
+ if len(matches) == 1:
+ adds.append(PlannedAdd(matches[0][0], line.quantity, matches[0][1]))
+ elif len(matches) > 1:
+ skipped.append(SkippedLine(line.name, "matches more than one menu item"))
+ elif key in unavailable:
+ skipped.append(SkippedLine(line.name, "unavailable on the menu"))
+ else:
+ skipped.append(SkippedLine(line.name, "not on the menu"))
+ return adds, skipped
+
+
+def _as_int(value: object) -> int:
+ if isinstance(value, bool) or value is None:
+ return 0
+ if isinstance(value, int):
+ return value
+ text = str(value).strip()
+ if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
+ return int(text)
+ return 0
diff --git a/src/scraper/parse_menu.py b/src/scraper/parse_menu.py
index 5d6bd96..5ee1f6e 100644
--- a/src/scraper/parse_menu.py
+++ b/src/scraper/parse_menu.py
@@ -30,16 +30,30 @@ class MenuParseError(RuntimeError):
"""The menu page did not contain a usable catalog."""
-def fetch_menu(url: str, *, timeout: float = 30) -> dict:
+def fetch_menu_html(url: str, *, timeout: float = 30) -> str:
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
- page = response.read().decode("utf-8", "replace")
+ return response.read().decode("utf-8", "replace")
except urllib.error.URLError as exc:
raise MenuParseError(f"Failed to fetch {url}") from exc
+
+
+def fetch_menu(url: str, *, timeout: float = 30) -> dict:
+ page = fetch_menu_html(url, timeout=timeout)
return parse_menu_html(page, menu_url=url)
+def extract_catalog_products(page: str) -> list:
+ """Return the raw product objects embedded on the menu page."""
+ products = _extract_json_attr(page, PRODUCTS_MARKER)
+ if not isinstance(products, list):
+ raise MenuParseError(":products must be a JSON array")
+ if not products:
+ raise MenuParseError("Menu catalog is empty")
+ return products
+
+
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
products = _extract_json_attr(page, PRODUCTS_MARKER)
newest = _extract_json_attr(page, NEWEST_MARKER)
diff --git a/tests/test_fill_cart.py b/tests/test_fill_cart.py
new file mode 100644
index 0000000..c19e0ec
--- /dev/null
+++ b/tests/test_fill_cart.py
@@ -0,0 +1,265 @@
+"""CSV parsing and menu matching for the Redefine cart filler."""
+
+import importlib.util
+from pathlib import Path
+
+from playwright.sync_api import Error as PlaywrightError
+
+from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for
+from scraper.parse_menu import extract_catalog_products
+
+_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "fill_redefine_cart.py"
+_spec = importlib.util.spec_from_file_location("fill_redefine_cart", _SCRIPT)
+fill_redefine_cart = importlib.util.module_from_spec(_spec)
+assert _spec.loader is not None
+_spec.loader.exec_module(fill_redefine_cart)
+
+PRODUCTS = [
+ {
+ "name": "Korean Steak Bowl",
+ "uuid": "korean-id",
+ "available": True,
+ },
+ {
+ "name": "Sale Bowl",
+ "uuid": "sale-id",
+ "available": True,
+ },
+ {
+ "name": "Hidden Bowl",
+ "uuid": "hidden-id",
+ "available": False,
+ },
+ {
+ "name": "Twin Bowl",
+ "uuid": "twin-a",
+ "available": True,
+ },
+ {
+ "name": "twin bowl",
+ "uuid": "twin-b",
+ "available": True,
+ },
+ {
+ "name": "No Id Bowl",
+ "uuid": "",
+ "available": True,
+ },
+]
+
+
+def test_build_plan_matches_names_and_skips_the_rest():
+ csv_text = "\n".join(
+ [
+ "Item,Quantity",
+ "korean steak bowl,4",
+ "Sale Bowl,2",
+ "Sale Bowl,3",
+ "Hidden Bowl,1",
+ "Twin Bowl,8",
+ "Missing Bowl,1",
+ "No Id Bowl,1",
+ "=cmd,1",
+ "Zero Bowl,0",
+ "Bad Qty,nope",
+ ]
+ )
+ # The admin exporter prefixes formula-like names with an apostrophe.
+ csv_text = csv_text.replace("=cmd", "'=cmd")
+
+ plan = build_plan(csv_text, PRODUCTS)
+
+ assert [(item.name, item.quantity, item.uuid) for item in plan.adds] == [
+ ("Korean Steak Bowl", 4, "korean-id"),
+ ("Sale Bowl", 5, "sale-id"),
+ ]
+ assert [(line.name, line.reason) for line in plan.skipped] == [
+ ("Bad Qty", "invalid quantity 'nope'"),
+ ("Hidden Bowl", "unavailable on the menu"),
+ ("Twin Bowl", "matches more than one menu item"),
+ ("Missing Bowl", "not on the menu"),
+ ("No Id Bowl", "not on the menu"),
+ ("=cmd", "not on the menu"),
+ ]
+ assert plan.ignored_zero == 1
+
+
+def test_build_plan_reads_quoted_fields_and_a_byte_order_mark():
+ csv_text = '\ufeffItem,Quantity\r\n"Bowl, Large",2\r\n'
+ products = [{"name": "Bowl, Large", "uuid": "bowl", "available": True}]
+
+ plan = build_plan(csv_text, products)
+
+ assert plan.adds[0].name == "Bowl, Large"
+ assert plan.adds[0].quantity == 2
+ assert plan.skipped == ()
+
+
+def test_build_plan_requires_the_admin_columns():
+ try:
+ build_plan("Meal,Qty\nSoup,1\n", PRODUCTS)
+ except ValueError as exc:
+ assert "Item and Quantity" in str(exc)
+ else:
+ raise AssertionError("expected a column error")
+
+
+def test_extract_catalog_products_reads_the_menu_attribute():
+ page = """
+
+ """
+
+ assert extract_catalog_products(page)[0]["uuid"] == "korean-id"
+
+
+def test_cart_lines_use_the_product_uuid_and_quantity():
+ cart = {
+ "items": [
+ {
+ "uuid": "line-1",
+ "quantity": "4",
+ "product": {"name": "Korean Steak Bowl", "uuid": "korean-id"},
+ }
+ ]
+ }
+
+ lines = cart_lines(cart)
+
+ assert lines[0].name == "Korean Steak Bowl"
+ assert lines[0].product_uuid == "korean-id"
+ assert quantity_for(lines, "korean-id") == 4
+ assert quantity_for(lines, "line-1") == 0
+
+
+class _CartPage:
+ def __init__(self, responses):
+ self._responses = list(responses)
+
+ def goto(self, url, **kwargs):
+ return None
+
+ def evaluate(self, script, payload):
+ response = self._responses.pop(0)
+ if isinstance(response, Exception):
+ raise response
+ return response
+
+
+def test_main_rejects_a_non_utf8_csv(tmp_path, capsys):
+ path = tmp_path / "order.csv"
+ path.write_bytes(b"Item,Quantity\nCaf\xe9 Bowl,1\n")
+
+ code = fill_redefine_cart.main([str(path)])
+ error = capsys.readouterr().err
+
+ assert code == 1
+ assert error.strip() == "CSV must be UTF-8."
+ assert "Traceback" not in error
+
+
+def test_fill_browser_prints_a_summary_when_the_page_cannot_open(capsys):
+ plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
+
+ class ClosedBrowser:
+ def __init__(self):
+ self.closed = False
+
+ def new_page(self):
+ raise PlaywrightError("browser has been closed")
+
+ def close(self):
+ self.closed = True
+
+ browser = ClosedBrowser()
+ code = fill_redefine_cart._fill_browser(
+ browser, plan, "https://www.redefinemeals.com/menu"
+ )
+ output = capsys.readouterr().out
+
+ assert code == 1
+ assert browser.closed
+ assert "Traceback" not in output
+ assert "Added 0. Failed 1. Skipped 0." in output
+
+
+def test_fill_page_prints_a_summary_when_the_window_closes_mid_add(capsys):
+ plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
+ page = _CartPage(
+ [
+ {"ok": True, "status": 200, "data": {"items": []}},
+ PlaywrightError("Target page, context or browser has been closed"),
+ ]
+ )
+
+ code = fill_redefine_cart._fill_page(
+ page, plan, "https://www.redefinemeals.com/menu"
+ )
+ output = capsys.readouterr().out
+
+ assert code == 1
+ assert "Traceback" not in output
+ assert "Added 0. Failed 1. Skipped 0." in output
+ assert "Cart is ready" not in output
+
+
+def test_fill_page_counts_items_left_when_the_window_closes(capsys):
+ plan = build_plan(
+ "Item,Quantity\nKorean Steak Bowl,1\nSale Bowl,2\n",
+ PRODUCTS,
+ )
+ page = _CartPage(
+ [
+ {"ok": True, "status": 200, "data": {"items": []}},
+ PlaywrightError("Target page, context or browser has been closed"),
+ ]
+ )
+
+ code = fill_redefine_cart._fill_page(
+ page, plan, "https://www.redefinemeals.com/menu"
+ )
+ output = capsys.readouterr().out
+
+ assert code == 1
+ assert "Korean Steak Bowl:" in output
+ assert "Sale Bowl: not attempted" in output
+ assert "Added 0. Failed 2. Skipped 0." in output
+
+
+def test_fill_page_prints_a_summary_when_the_first_cart_read_fails(capsys):
+ plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
+ page = _CartPage(
+ [
+ {
+ "ok": False,
+ "status": 500,
+ "data": {"message": "cart unavailable", "trace": [{"file": "x"}]},
+ }
+ ]
+ )
+
+ code = fill_redefine_cart._fill_page(
+ page, plan, "https://www.redefinemeals.com/menu"
+ )
+ output = capsys.readouterr().out
+
+ assert code == 1
+ assert "HTTP 500: cart unavailable" in output
+ assert "x" not in output.split("Failed:", 1)[-1]
+ assert "Added 0. Failed 1. Skipped 0." in output
+
+
+def test_error_text_keeps_the_message_and_drops_the_trace():
+ body = {
+ "message": "Product is not available.",
+ "exception": "HttpException",
+ "file": "/home/app/secret.php",
+ "trace": [{"file": "/home/app/secret.php"}],
+ }
+
+ text = error_text(422, body)
+
+ assert text == "HTTP 422: Product is not available."
+ assert "secret.php" not in text
diff --git a/tests/test_parse_menu.py b/tests/test_parse_menu.py
index a06ad61..2256f33 100644
--- a/tests/test_parse_menu.py
+++ b/tests/test_parse_menu.py
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
-from scraper.parse_menu import MenuParseError, parse_menu_html
+from scraper.parse_menu import MenuParseError, extract_catalog_products, parse_menu_html
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
@@ -43,6 +43,12 @@ def test_parse_menu_html_rejects_a_missing_catalog():
parse_menu_html("", menu_url="https://example.com/menu")
+def test_extract_catalog_products_rejects_an_empty_catalog():
+ page = ""
+ with pytest.raises(MenuParseError, match="empty"):
+ extract_catalog_products(page)
+
+
def test_parse_menu_html_rejects_an_empty_catalog():
page = ""
with pytest.raises(MenuParseError, match="empty"):