mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-03 18:53:15 +00:00
Compare commits
3 commits
dce33918b7
...
ddf150f428
| Author | SHA1 | Date | |
|---|---|---|---|
| ddf150f428 | |||
| 9462ce0966 | |||
| ca04a117f5 |
4 changed files with 214 additions and 46 deletions
|
|
@ -79,6 +79,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||
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
|
||||
|
|
@ -102,46 +105,75 @@ def _fill(plan: FillPlan, menu_url: str) -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
page = browser.new_page()
|
||||
return _fill_browser(browser, plan, menu_url)
|
||||
|
||||
|
||||
def _fill_browser(browser, plan: FillPlan, menu_url: str) -> int:
|
||||
try:
|
||||
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
|
||||
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
|
||||
|
||||
failed: list[str] = []
|
||||
posted = []
|
||||
for item in 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
|
||||
posted.append(item)
|
||||
|
||||
added = 0
|
||||
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:
|
||||
|
|
@ -160,14 +192,14 @@ def _fill(plan: FillPlan, menu_url: str) -> int:
|
|||
added += 1
|
||||
origin = _origin(page.url or menu_url)
|
||||
page.goto(f"{origin}/cart", wait_until="domcontentloaded", timeout=60000)
|
||||
print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.")
|
||||
_wait_for_review(page)
|
||||
return 1 if failed or plan.skipped else 0
|
||||
finally:
|
||||
try:
|
||||
browser.close()
|
||||
except PlaywrightError:
|
||||
pass
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ def extract_catalog_products(page: str) -> list:
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
"""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",
|
||||
|
|
@ -123,6 +134,123 @@ def test_cart_lines_use_the_product_uuid_and_quantity():
|
|||
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.",
|
||||
|
|
|
|||
|
|
@ -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("<html></html>", menu_url="https://example.com/menu")
|
||||
|
||||
|
||||
def test_extract_catalog_products_rejects_an_empty_catalog():
|
||||
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||
with pytest.raises(MenuParseError, match="empty"):
|
||||
extract_catalog_products(page)
|
||||
|
||||
|
||||
def test_parse_menu_html_rejects_an_empty_catalog():
|
||||
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||
with pytest.raises(MenuParseError, match="empty"):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue