mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 04:41:57 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39c8b27ea | ||
|
|
f2ef1df881 | ||
|
|
2fe7133411 | ||
|
|
cfa14c4240 | ||
|
|
3bd98aad8d | ||
|
|
7943207e97 | ||
|
|
c15ea8cee1 | ||
|
|
69359df69d | ||
|
|
bf1f3ea182 |
17 changed files with 828 additions and 73 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -12,7 +12,7 @@ permissions:
|
|||
jobs:
|
||||
autofix:
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
lint:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
with:
|
||||
python-version: "3.12.14"
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ jobs:
|
|||
terraform:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
with:
|
||||
terraform-version: "1.9.8"
|
||||
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -7,4 +7,4 @@ permissions:
|
|||
|
||||
jobs:
|
||||
review:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
|
|
|
|||
4
.github/workflows/deploy-api.yaml
vendored
4
.github/workflows/deploy-api.yaml
vendored
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
deploy-dev:
|
||||
name: Deploy API to dev
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
deploy-prod:
|
||||
name: Deploy API to prod
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -10,4 +10,4 @@ permissions:
|
|||
|
||||
jobs:
|
||||
label:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
|
|
|
|||
|
|
@ -12,10 +12,6 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
|
|||
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
|
||||
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
|
||||
|
||||
## Security and Cross-Review
|
||||
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
|
||||
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
|
||||
|
||||
## CI and Workflow References
|
||||
- CI must pass before merge.
|
||||
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.
|
||||
|
|
|
|||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -9,10 +9,10 @@
|
|||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@redocly/cli": "2.54.2",
|
||||
"@redocly/cli": "2.57.0",
|
||||
"eslint": "10.11.0",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.8"
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory": {
|
||||
|
|
@ -258,9 +258,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/cli": {
|
||||
"version": "2.54.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz",
|
||||
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==",
|
||||
"version": "2.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.57.0.tgz",
|
||||
"integrity": "sha512-1d5fVyUaYlMNgCHUoyE2vUyxBhs/jmOHgsX/kFmfWzESw4f/G/OV/SU9E55rU7aUNmw9rHj1vXmL6yUdIn+KKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
@ -344,9 +344,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -659,9 +659,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
|
||||
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -906,9 +906,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@redocly/cli": "2.54.2",
|
||||
"@redocly/cli": "2.57.0",
|
||||
"eslint": "10.11.0",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.8"
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
flask==3.1.3
|
||||
gunicorn==26.2.0
|
||||
boto3==1.43.98
|
||||
sentry-sdk==2.69.2
|
||||
boto3==1.43.107
|
||||
sentry-sdk==2.71.0
|
||||
jinja2==3.1.6
|
||||
fpdf2==2.8.8
|
||||
PyJWT[crypto]==2.14.0
|
||||
fpdf2==2.8.9
|
||||
PyJWT[crypto]==2.15.1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
playwright==1.63.0
|
||||
flask==3.1.3
|
||||
boto3==1.43.98
|
||||
boto3==1.43.107
|
||||
jinja2==3.1.6
|
||||
|
|
|
|||
271
scripts/fill_redefine_cart.py
Normal file
271
scripts/fill_redefine_cart.py
Normal file
|
|
@ -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())
|
||||
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,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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
boto3==1.43.98
|
||||
fpdf2==2.8.8
|
||||
PyJWT[crypto]==2.14.0
|
||||
boto3==1.43.107
|
||||
fpdf2==2.8.9
|
||||
PyJWT[crypto]==2.15.1
|
||||
|
|
|
|||
64
terraform/.terraform.lock.hcl
generated
64
terraform/.terraform.lock.hcl
generated
|
|
@ -2,40 +2,40 @@
|
|||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.65.0"
|
||||
constraints = "6.65.0"
|
||||
version = "6.67.0"
|
||||
constraints = "6.67.0"
|
||||
hashes = [
|
||||
"h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=",
|
||||
"h1:1QFvuV0+K3GieeXSlb7qi9Q334XrsnMP8dCRwpM7fkc=",
|
||||
"h1:36ZBGQTzM6dW8dLQ145loI9yw6/fSbRV+fvLGCeEubc=",
|
||||
"h1:4uHlr+eDGOjf71giwLidIfGsMP6g5x2wkSGP5xq9EpM=",
|
||||
"h1:9fSxZKfaAGrNSzXIz53IP2EmC1LYdO/fGYl7T87Y36Q=",
|
||||
"h1:GJCK46UtrJMGSyByH4SiDytbwX11bg79jvTGZ27BGWU=",
|
||||
"h1:H0qzEAMrqydb8eTZdebso8nS/b8w1BNHysP8nmM4pLk=",
|
||||
"h1:RjeO6m/SvlhGUCDrwTdj99kJhKl/rC1zE9B5mi3YhVw=",
|
||||
"h1:Yx8Kv/T/BHVE8S1WEyHsFdu4HcsAQIl5e/831DeoQ5k=",
|
||||
"h1:ZFDxAUFzk1A2BPbLgu1LhAJ3erD4BbqZsF25yQobN4o=",
|
||||
"h1:anUZ356aBWvbHzQalF036qNKO+RISPmq6ycIL4OdXxk=",
|
||||
"h1:fhsSsZmfNFf4wErbcsmu1/ek1/TVUy7NCuMYeOpA1aE=",
|
||||
"h1:l+w5eqL9UqpiVZ2ll0r+YvWAPjIL/WtqV8xqfH1+apM=",
|
||||
"h1:nt0kyMKN9kDNXKHOejaAo7LQIemP3tyntYjxHY32P0M=",
|
||||
"h1:o2tj5YHQU1QNgANW2YAbjj0opl0BRJZl8W9trjYsqdA=",
|
||||
"zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be",
|
||||
"zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3",
|
||||
"zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917",
|
||||
"zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8",
|
||||
"zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09",
|
||||
"zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8",
|
||||
"zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7",
|
||||
"zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5",
|
||||
"zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294",
|
||||
"zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e",
|
||||
"h1:01Y50Z+67vWZmsW9e8FQTj9NT/XW+x1n0YGdz2x4BpY=",
|
||||
"h1:6dffiL4BGVg+Ac2/64N+svhucYstBnVTTu55hWEhmq8=",
|
||||
"h1:8kRViFkyn96SufnuV3Oi3QmfL+DiyxlhAPcSX2jH0T0=",
|
||||
"h1:EAfdlvAeLCxhO9G5nnZ6Ti1zsmQP1aClgS41rneOijY=",
|
||||
"h1:EB9ixYOZrSlYD7wtJxf88qwoyyWrlKDKxhzaCLIb3t4=",
|
||||
"h1:Ksjd+RJVccOFRlbg3gpoJjL7T3SMPHxso2dPzAVTyRE=",
|
||||
"h1:OgdIUAQDtJBxlKjoPgChD1w57vl6hm6PyJHiBYgsgQA=",
|
||||
"h1:Syy68cIDOz7sXpxhjrkCwNHvmOj9VeaLVTJ/XnUKSuU=",
|
||||
"h1:Tz5wi4X4Gkj2I1Gif4MOtfrj4JerCz+5KqSi6Xj+n/A=",
|
||||
"h1:XaBXhLvtX5lUYkv5fHKzzMfoZXaIh5zU8hvM4jG2TXI=",
|
||||
"h1:fOwuAcOFTGOU0GY1m4NHhDgTAdTSiU+9qZ+REdp5HIU=",
|
||||
"h1:gyduBRrLO8EiRf8zA1aiLRoWydrUMw+TG0pUbOXo1g4=",
|
||||
"h1:iDfjW95J79sAMaOxeln73bKerm9SBemvLTrKepODumw=",
|
||||
"h1:xH+es0QQOteWlNptLqZzI6k8y94Aq/11S7lozotvO2c=",
|
||||
"h1:zLmFaFw5LptpWftHL3O6+7uykOH/0F9AmyVQ7V6kslY=",
|
||||
"zh:111d5686a1f4ccbc888bb5e2229308bcdd9149898c6af97969fcdfb0e7bd2aa0",
|
||||
"zh:21b3b7693bd9754c039fd546f03ed09e7510c6189aa5ee37176f144cf8dd5f95",
|
||||
"zh:25e03c7f025ff4851889537605aa560d2e39bd738b33a5204b8037eb164b475f",
|
||||
"zh:2817a046f1060e25bf04b0eb78f4e251130bb92dc82ec1264ce156dc9bf78e67",
|
||||
"zh:2d318d674c3ec9dbd97ddb10b12ad4827be4f508c43ba2ae1e0523baa9e367e3",
|
||||
"zh:2f07ab356718267299e10b6072472ded29d82753883027bf43bcd687ac72feb2",
|
||||
"zh:32307e67f83bc0dc94d38cfcd23d782166a09e0e975e2a5aa7d7a3e7ca5d3da8",
|
||||
"zh:4ce94853264097dd7f4542b3cfd18d2b98b06d23321db45d5e384ea275a57f63",
|
||||
"zh:869656c41cc7c7412f213e488f98167cff61deafcb8cf245d25d056e8dfdc263",
|
||||
"zh:8fd8c814e9d8edf152552047db23bf5b5d63f22e6b0b5d47b2af851376e99349",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f",
|
||||
"zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab",
|
||||
"zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af",
|
||||
"zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87",
|
||||
"zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59",
|
||||
"zh:9cbc02ceef9bd469da497a1e7065ff984bdacfbe919ac3cce804a86c13b6e2c6",
|
||||
"zh:9ea55dda2767acfc1f6337fc7d29b1d4d79d6f8f04871f8ad99a6078d2865994",
|
||||
"zh:d7149df0819fb57a160489db45d33951765b8f0118daa3b4cd549a0135bc97a1",
|
||||
"zh:e5819937bb7043d08c9829b32d52d9fb55a5b9a4d8d55d550d47a3d7392db546",
|
||||
"zh:f93bc38dbc0ad53842303f30eec7a22c525c4d56209b54ec33a8d375cfc4e4fd",
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ terraform {
|
|||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "6.65.0"
|
||||
version = "6.67.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
|
|
|
|||
265
tests/test_fill_cart.py
Normal file
265
tests/test_fill_cart.py
Normal file
|
|
@ -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 = """
|
||||
<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
|
||||
|
|
@ -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