mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 04:13:12 +00:00
* 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.
265 lines
7.4 KiB
Python
265 lines
7.4 KiB
Python
"""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 = """
|
|
<orders-page
|
|
:products='[{"name":"Korean Steak Bowl","uuid":"korean-id","available":true}]'
|
|
:newest-ids='[]'
|
|
></orders-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
|