mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-04 19:32:00 +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.
This commit is contained in:
parent
cc506f3c1f
commit
dce33918b7
4 changed files with 593 additions and 2 deletions
239
scripts/fill_redefine_cart.py
Normal file
239
scripts/fill_redefine_cart.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""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 (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
|
||||||
|
page = browser.new_page()
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
203
src/scraper/fill_cart.py
Normal file
203
src/scraper/fill_cart.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -30,16 +30,28 @@ class MenuParseError(RuntimeError):
|
||||||
"""The menu page did not contain a usable catalog."""
|
"""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})
|
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
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:
|
except urllib.error.URLError as exc:
|
||||||
raise MenuParseError(f"Failed to fetch {url}") from 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)
|
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")
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
|
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
|
||||||
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
||||||
newest = _extract_json_attr(page, NEWEST_MARKER)
|
newest = _extract_json_attr(page, NEWEST_MARKER)
|
||||||
|
|
|
||||||
137
tests/test_fill_cart.py
Normal file
137
tests/test_fill_cart.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""CSV parsing and menu matching for the Redefine cart filler."""
|
||||||
|
|
||||||
|
from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for
|
||||||
|
from scraper.parse_menu import extract_catalog_products
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Loading…
Add table
Reference in a new issue