Meal validation improvements

This commit is contained in:
jableader 2025-11-02 17:45:12 +11:00
parent ca735865fc
commit b7a737ddaa
7 changed files with 344 additions and 52 deletions

View file

@ -233,8 +233,46 @@ async def create_meal_scoped(
msg = meals.validate_meal(domain_meal) msg = meals.validate_meal(domain_meal)
if msg: if msg:
return error_response(request, 400, msg) return error_response(request, 400, msg)
# Proactive validation via repositories
hid = household["id"] hid = household["id"]
# Validate members exist as users (do not require household membership here to preserve existing behavior/tests)
member_ids = {m.id for m in (*domain_meal.chefs, *domain_meal.cleanup, *domain_meal.consumers)}
if member_ids:
from users.repository import get_by_ids as get_users_by_ids
users = await get_users_by_ids(conn, sorted(member_ids))
valid_ids = set(users.keys())
invalid = sorted(member_ids - valid_ids)
if invalid:
return error_response(request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}")
# Validate recipes (existence and household scope) via recipes repository
if domain_meal.recipes:
from recipes.repository import find_recipe_by_id_scoped, find_recipe_by_id
invalid_recipes: list[int] = []
for r in domain_meal.recipes:
rid = int(r.recipe_id) if r.recipe_id is not None else -1
if rid < 0:
invalid_recipes.append(rid)
continue
recipe = await find_recipe_by_id_scoped(conn, rid, hid)
if not recipe:
# Fallback to global existence if scoping isn't set on that record
recipe = await find_recipe_by_id(conn, rid)
if not recipe:
invalid_recipes.append(rid)
if invalid_recipes:
return error_response(request, 400, f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}")
try:
await meals.insert_meal_scoped(conn, domain_meal, hid) await meals.insert_meal_scoped(conn, domain_meal, hid)
except aiosqlite.IntegrityError:
# Likely an invalid foreign key (unknown member or recipe id)
return error_response(
request,
400,
"Invalid member or recipe id. Ensure participant IDs are valid household members and recipes exist.",
)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}" response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
return MealOut( return MealOut(
@ -290,6 +328,34 @@ async def update_meal_scoped(
msg = meals.validate_meal(domain_meal) msg = meals.validate_meal(domain_meal)
if msg: if msg:
return error_response(request, 400, msg) return error_response(request, 400, msg)
# Proactive validation similar to create (user existence only)
member_ids = {m.id for m in (*domain_meal.chefs, *domain_meal.cleanup, *domain_meal.consumers)}
hid = household["id"]
if member_ids:
from users.repository import get_by_ids as get_users_by_ids
users = await get_users_by_ids(conn, sorted(member_ids))
valid_ids = set(users.keys())
invalid = sorted(member_ids - valid_ids)
if invalid:
return error_response(request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}")
if domain_meal.recipes:
from recipes.repository import find_recipe_by_id_scoped, find_recipe_by_id
invalid_recipes: list[int] = []
for r in domain_meal.recipes:
rid = int(r.recipe_id) if r.recipe_id is not None else -1
if rid < 0:
invalid_recipes.append(rid)
continue
recipe = await find_recipe_by_id_scoped(conn, rid, hid)
if not recipe:
recipe = await find_recipe_by_id(conn, rid)
if not recipe:
invalid_recipes.append(rid)
if invalid_recipes:
return error_response(request, 400, f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}")
await meals.update_meal(conn, domain_meal) await meals.update_meal(conn, domain_meal)
# Return updated state # Return updated state
updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid) updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)

View file

@ -16,13 +16,13 @@
- POST `/api/v1/households/{householdSlug}/shopping/current/ingredients` requests an adhoc ingredient scoped to household+user; duplicates deduped per user per household. - POST `/api/v1/households/{householdSlug}/shopping/current/ingredients` requests an adhoc ingredient scoped to household+user; duplicates deduped per user per household.
- DELETE `/api/v1/households/{householdSlug}/shopping/current/ingredients` removes an adhoc ingredient request for the current user in this household; idempotent. - DELETE `/api/v1/households/{householdSlug}/shopping/current/ingredients` removes an adhoc ingredient request for the current user in this household; idempotent.
- POST `/api/v1/households/{householdSlug}/recipes/parse-from-url` returns a RecipeCreate payload parsed from a URL (stateless; household auth enforced). Shape matches the body accepted by `POST /recipes`. - POST `/api/v1/households/{householdSlug}/recipes/parse-from-url` returns a RecipeCreate payload parsed from a URL (stateless; household auth enforced). Shape matches the body accepted by `POST /recipes`.
- GET `/api/v1/households/{householdSlug}/ingredients/parse?line=...` parses a single ingredient line into an `Ingredient` (scoped; JWT + membership). Attempts best-effort product matching. - GET `/api/v1/households/{householdSlug}/ingredients/parse` parses ingredients (scoped; JWT + membership). Supports either `?line=...` (returns a single `Ingredient`) or repeated `?lines=...` query params (returns `Ingredient[]`). Attempts best-effort product matching.
- Comprehensive v2 coverage exists for scoping, purchases, request/unrequest, meals CRUD/consumed, and OpenAPI security. PASS. - Comprehensive v2 coverage exists for scoping, purchases, request/unrequest, meals CRUD/consumed, and OpenAPI security. PASS.
# Backend Specification: Household Multi-Tenancy (v2) # Backend Specification: Household Multi-Tenancy (v2)
This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria. This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria.
Date reviewed: 2025-11-01 (post-cutover: JWT + households live, Argon2-only, members endpoint added, v2 routers consolidated; all checks green; OpenAPI exported; persons fully removed) Date reviewed: 2025-11-02 (post-cutover: JWT + households live, Argon2-only, members endpoint added, v2 routers consolidated; all checks green; OpenAPI exported; persons fully removed)
Repo modules checked: `main.py`, `api/*` (v2-only; no `*_v2.py` files remain), `users/*`, `households/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. The legacy `persons/*` package has been deleted. Repo modules checked: `main.py`, `api/*` (v2-only; no `*_v2.py` files remain), `users/*`, `households/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. The legacy `persons/*` package has been deleted.
@ -54,7 +54,7 @@ Special-case 401: Removed. v1 cookie-based auth and routes have been retired in
- GET `/api/v1/recipes``Page<Recipe>`; loads ingredients per page. - GET `/api/v1/recipes``Page<Recipe>`; loads ingredients per page.
- GET `/api/v1/recipes/{id}` → full recipe (ingredients + createdBy). - GET `/api/v1/recipes/{id}` → full recipe (ingredients + createdBy).
- POST `/api/v1/households/{householdSlug}/recipes/parse-from-url` (auth required) → scrape/parse a recipe URL; returns `RecipeCreate` (no id/createdBy); 404 if not found. - POST `/api/v1/households/{householdSlug}/recipes/parse-from-url` (auth required) → scrape/parse a recipe URL; returns `RecipeCreate` (no id/createdBy); 404 if not found.
- GET `/api/v1/households/{householdSlug}/ingredients/parse?line=...` → parse a single raw ingredient line (JWT + membership); matches existing products. - GET `/api/v1/households/{householdSlug}/ingredients/parse` → parse ingredients, supporting `line` (single) or repeated `lines` params (batch). JWT + membership; matches existing products.
- POST `/api/v1/recipes` (auth required) → validate (≥1 ingredient), perform versioning (hide base if id≥0), set createdById, insert ingredients; sets `Location` header. - POST `/api/v1/recipes` (auth required) → validate (≥1 ingredient), perform versioning (hide base if id≥0), set createdById, insert ingredients; sets `Location` header.
- DELETE `/api/v1/recipes/{id}` (auth required) → soft-delete (hide) recipe. - DELETE `/api/v1/recipes/{id}` (auth required) → soft-delete (hide) recipe.
@ -202,7 +202,7 @@ Route surface lockdown:
- `users` with tables `User`, `LocalCredentials`, `OAuthCredentials` (see `users/repository.py`). - `users` with tables `User`, `LocalCredentials`, `OAuthCredentials` (see `users/repository.py`).
- `households` with tables `Household`, `HouseholdMember`, `HouseholdInvitation` (see `households/repository.py`). - `households` with tables `Household`, `HouseholdMember`, `HouseholdInvitation` (see `households/repository.py`).
- `db.create()` now initializes these tables alongside existing v1 tables. - `db.create()` now initializes these tables alongside existing v1 tables.
- ✅ **Bootstrap**: `db.create()` initializes all v2 tables, including `users` and `households`, and tenant tables already include `household_id` columns and indices by default. No separate migration script is required for fresh databases used by tests. - ✅ **Bootstrap**: `db.create()` initializes all v2 tables, including `users` and `households`, and tenant tables already include `household_id` columns and indices by default. The legacy ad-hoc migration script has been removed; tests and dev use fresh DDL via `db.create()`.
### Feature parity checklist (OpenAPI diffs vs master) ### Feature parity checklist (OpenAPI diffs vs master)
@ -210,7 +210,7 @@ Completed:
- Auth endpoints (`/api/v1/auth/*`) migrated to JWT with tokens/refresh cookie. - Auth endpoints (`/api/v1/auth/*`) migrated to JWT with tokens/refresh cookie.
- Household-scoped recipes/meals/shopping endpoints in place. - Household-scoped recipes/meals/shopping endpoints in place.
- `POST /api/v1/households/{householdSlug}/recipes/parse-from-url` implemented (returns `RecipeCreate`). - `POST /api/v1/households/{householdSlug}/recipes/parse-from-url` implemented (returns `RecipeCreate`).
- `GET /api/v1/households/{householdSlug}/ingredients/parse` implemented (returns single `Ingredient`, scoped). - `GET /api/v1/households/{householdSlug}/ingredients/parse` implemented with dual modes: `?line=...` (single `Ingredient`) or repeated `?lines=...` (returns `Ingredient[]`). Scoped.
Outstanding (tracked): Outstanding (tracked):
- None identified blocking parity for shopping/recipes needed by the frontend as of 2025-11-01. Re-check if any v1 product scrape/create endpoint needs re-exposure; current frontend uses household flows and parsing utilities. - None identified blocking parity for shopping/recipes needed by the frontend as of 2025-11-01. Re-check if any v1 product scrape/create endpoint needs re-exposure; current frontend uses household flows and parsing utilities.
@ -311,14 +311,12 @@ Status summary:
- Current v2 recipes shape: `createdById` and `createdBy` (MemberRef) are included. Delete now returns `hiddenById` and `hiddenBy` (MemberRef) for the acting user. - Current v2 recipes shape: `createdById` and `createdBy` (MemberRef) are included. Delete now returns `hiddenById` and `hiddenBy` (MemberRef) for the acting user.
Remaining work (prioritized cleanup to final state): Remaining work (prioritized cleanup to final state):
1. Remove the `persons/` package and all code references across domains (recipes, meals, shopping). Replace `Person` with `users`/HouseholdMember everywhere: 1. Remove any remaining references to legacy `Person` semantics in comments or deep internals; ensure all code paths exclusively use Users and HouseholdMember.
- Code hotspots today: `recipes/models.py` (imports Person), `recipes/repository.py` (FKs, hide_recipe signature), `meals/models.py` (participants as List[Person]), `api/shopping_models.py` and `shopping/models.py` (ShoppingList.purchased_by typed as Person), and `shopping/repository.py` (FKs to Person). - Verify repositories and models have no lingering Person FKs or types; outward schemas consistently use `MemberRef`.
- Replace internal usages with `user_id`/`MemberRef` where outward, and update repository DDL to stop referencing `Person`.
- Removed `api.deps.cookie_person` and `cookie_person_optional`; v1 tests referencing them remain skipped and will be deleted or ported.
- Ensure no API module performs direct SQL; all persistence must flow through repositories (enforced during cleanup). - Ensure no API module performs direct SQL; all persistence must flow through repositories (enforced during cleanup).
2. Recipes outward schema: DONE for `createdById`/`createdBy` and `hiddenById`/`hiddenBy` (MemberRef on delete). If/when we expose hide actor on other flows, keep MemberRef. 2. Recipes outward schema: DONE for `createdById`/`createdBy` and `hiddenById`/`hiddenBy` (MemberRef on delete). If/when we expose hide actor on other flows, keep MemberRef.
3. Shopping outward DTOs: DONE — `ShoppingListOut.purchasedBy` is a MemberRef; mapping added. Ensure clients shift to `displayName`. 3. Shopping outward DTOs: DONE — `ShoppingListOut.purchasedBy` is a MemberRef; mapping added. Ensure clients shift to `displayName`.
4. Delete or port legacy v1 test modules that are currently skipped (`tests/test_main.py`, `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Then remove the last vestiges of v1-only helpers. 4. Delete or port any remaining legacy v1 test modules that are currently skipped (e.g., `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Remove the last vestiges of v1-only helpers.
5. Database polish: 5. Database polish:
- Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem). - Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem).
- Add explicit FK constraints from tenant tables to `Household(id)` where safe. - Add explicit FK constraints from tenant tables to `Household(id)` where safe.

View file

@ -46,3 +46,32 @@ async def create(conn):
await conn.execute( await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);" "CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);"
) )
async def member_ids_in_household(conn, household_id: int, user_ids: list[int]) -> set[int]:
"""Return the subset of user_ids that are members of the given household.
Uses HouseholdMember join User to ensure users exist.
"""
if not user_ids:
return set()
placeholders = ",".join(["?"] * len(user_ids))
query = f"""
SELECT u.id
FROM HouseholdMember hm
JOIN User u ON u.id = hm.user_id
WHERE hm.household_id = ? AND u.id IN ({placeholders})
"""
valid: set[int] = set()
async with conn.execute(query, (household_id, *sorted(user_ids))) as c:
async for row in c:
valid.add(int(row[0]))
return valid
async def are_members(conn, household_id: int, user_ids: list[int]) -> bool:
"""True only if all provided user_ids are members of the household."""
if not user_ids:
return True
found = await member_ids_in_household(conn, household_id, user_ids)
return found == set(user_ids)

View file

@ -771,7 +771,7 @@
"ingredients", "ingredients",
"ingredients" "ingredients"
], ],
"summary": "Parse an ingredient line from a string", "summary": "Parse an ingredient line or lines from a string",
"operationId": "parse_ingredient_api_v1_households__householdSlug__ingredients_parse_get", "operationId": "parse_ingredient_api_v1_households__householdSlug__ingredients_parse_get",
"parameters": [ "parameters": [
{ {
@ -783,15 +783,40 @@
"title": "Householdslug" "title": "Householdslug"
} }
}, },
{
"name": "line",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Single ingredient line to parse",
"title": "Line"
},
"description": "Single ingredient line to parse"
},
{ {
"name": "lines", "name": "lines",
"in": "query", "in": "query",
"required": true, "required": false,
"schema": { "schema": {
"anyOf": [
{
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}
}, },
{
"type": "null"
}
],
"description": "Multiple ingredient lines to parse", "description": "Multiple ingredient lines to parse",
"title": "Lines" "title": "Lines"
}, },
@ -804,10 +829,17 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"anyOf": [
{
"$ref": "#/components/schemas/Ingredient"
},
{
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/Ingredient" "$ref": "#/components/schemas/Ingredient"
}, }
}
],
"title": "Response Parse Ingredient Api V1 Households Householdslug Ingredients Parse Get" "title": "Response Parse Ingredient Api V1 Households Householdslug Ingredients Parse Get"
} }
} }

View file

@ -1,23 +1,25 @@
import json import json
from typing import Optional from typing import Optional, Iterable
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
HEADERS = { # A realistic browser header profile improves success rates against some CDNs/bot protections.
DEFAULT_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5", "Accept-Language": "en-US,en;q=0.9",
"DNT": "1", "Accept-Encoding": "gzip, deflate, br",
"Sec-GPC": "1",
"Connection": "keep-alive", "Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1", "Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document", "Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate", "Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none", "Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1", "Sec-Fetch-User": "?1",
"Priority": "u=1", # A modern desktop Chrome UA with platform tokens; not tied to any user data.
"Pragma": "no-cache", "User-Agent": (
"Cache-Control": "no-cache", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/127.0.0.0 Safari/537.36"
),
} }
@ -33,14 +35,61 @@ def _is_recipe_ldata(ldata_node) -> bool:
return False return False
async def scrape_recipe_ldata(url: str) -> Optional[dict]: def _fallback_urls(url: str) -> Iterable[str]:
# Load the requested URL with headers """Generate fallback URLs to try if the primary request is blocked.
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=HEADERS, follow_redirects=True)
if response.status_code >= 300:
return None
# Extract the recipe ld+json data Strategy:
- original URL
- add `?output=amp` if no existing query
- add `&output=amp` if query exists
- try `/amp` path suffix if not already present
"""
yield url
try:
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
parsed = urlparse(url)
q = dict(parse_qsl(parsed.query, keep_blank_values=True))
if q.get("output") != "amp":
q["output"] = "amp"
amp_url = urlunparse(
parsed._replace(query=urlencode(q, doseq=True))
)
if amp_url != url:
yield amp_url
# Try a path-based AMP fallback
if not parsed.path.endswith("/amp"):
amp_path = parsed.path.rstrip("/") + "/amp"
amp2 = urlunparse(parsed._replace(path=amp_path))
if amp2 != url:
yield amp2
except Exception:
# Be conservative if URL parsing fails
pass
BLOCK_STATUSES = {403, 406, 429, 460}
async def scrape_recipe_ldata(url: str) -> Optional[dict]:
# Try the URL with browser-like headers and fallback strategies when blocked.
async with httpx.AsyncClient() as client:
for candidate in _fallback_urls(url):
# Some CDNs prefer a referer; provide same-origin referer as a harmless hint.
headers = dict(DEFAULT_HEADERS)
headers.setdefault("Referer", candidate)
response = await client.get(
candidate, headers=headers, follow_redirects=True
)
if response.status_code in BLOCK_STATUSES:
# Try next fallback
continue
if response.status_code >= 300:
# Try next fallback on non-2xx
continue
# Extract the recipe ld+json data from this candidate
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
for ld in soup.find_all("script", type="application/ld+json"): for ld in soup.find_all("script", type="application/ld+json"):
try: try:

View file

@ -171,3 +171,64 @@ class TestMealsWriteV2(unittest.IsolatedAsyncioTestCase):
f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers
) )
assert r_del_nf.status_code == 404 assert r_del_nf.status_code == 404
def test_create_meal_invalid_member_id_returns_400(self):
# Body with an invalid member id 9999
body = {
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
"chefs": [{"id": 9999, "displayName": "X"}],
"cleanup": [{"id": 1, "displayName": "A"}],
"consumers": [{"id": 1, "displayName": "A"}],
"recipes": [],
"extraIngredients": [
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
],
}
r = self.client.post(
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
)
assert r.status_code == 400
assert "member" in r.json()["title"].lower()
def test_create_meal_invalid_recipe_id_returns_400(self):
# Use a non-existent recipe id; servings > 0
body = {
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
"chefs": [{"id": 1, "displayName": "A"}],
"cleanup": [{"id": 1, "displayName": "A"}],
"consumers": [{"id": 1, "displayName": "A"}],
"recipes": [{"mealId": -1, "recipeId": 9999, "servings": 1}],
"extraIngredients": [],
}
r = self.client.post(
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
)
assert r.status_code == 400
assert "recipe" in r.json()["title"].lower()
def test_update_meal_invalid_member_id_returns_400(self):
# Create a valid meal first
body = {
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
"chefs": [{"id": 1, "displayName": "A"}],
"cleanup": [{"id": 1, "displayName": "A"}],
"consumers": [{"id": 1, "displayName": "A"}],
"recipes": [],
"extraIngredients": [
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
],
}
r = self.client.post(
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
)
assert r.status_code == 200, r.text
created = r.json()
meal_id = created["id"]
# Attempt to update with an invalid member id
created["chefs"] = [{"id": 9999, "displayName": "X"}]
r2 = self.client.put(
f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers, json=created
)
assert r2.status_code == 400
assert "member" in r2.json()["title"].lower()

View file

@ -57,7 +57,8 @@ class TestRecipesParseFromUrlIntegrationV2(unittest.IsolatedAsyncioTestCase):
return False return False
async def get(self, url, headers=None, follow_redirects=False): async def get(self, url, headers=None, follow_redirects=False):
assert url == SAMPLE_URL # Allow the scraper to call the base URL or an AMP fallback
assert url == SAMPLE_URL or (url.startswith(SAMPLE_URL) and ("output=amp" in url or url.endswith("/amp")))
return DummyResp(200, SAMPLE_HTML) return DummyResp(200, SAMPLE_HTML)
orig_client = scraping.httpx.AsyncClient orig_client = scraping.httpx.AsyncClient
@ -109,3 +110,59 @@ class TestRecipesParseFromUrlIntegrationV2(unittest.IsolatedAsyncioTestCase):
assert body.get("status") == 404 assert body.get("status") == 404
finally: finally:
scraping.httpx.AsyncClient = orig_client scraping.httpx.AsyncClient = orig_client
def test_parse_from_url_audab_460_then_amp_fallback(self):
"""Simulate an AUDAB 460 block on the canonical URL, then succeed on an AMP variant.
Also verify we send browser-like headers including User-Agent and Accept-Encoding.
"""
import recipes.scraping as scraping
calls = []
class DummyResp:
def __init__(self, status_code=200, text=""):
self.status_code = status_code
self.text = text
class DummyClient:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url, headers=None, follow_redirects=False):
# record call
calls.append((url, headers or {}))
# First attempt to the canonical URL returns AUDAB 460
if url == SAMPLE_URL:
# Ensure headers include browser-like values
ua = (headers or {}).get("User-Agent", "")
assert "Mozilla" in ua or "Chrome" in ua
ae = (headers or {}).get("Accept-Encoding", "")
assert "gzip" in ae
# Simulate block
return DummyResp(460, "AUDAB - Not Allowed")
# Fallback attempt(s): '?output=amp' should succeed
if url.startswith(SAMPLE_URL) and "output=amp" in url:
return DummyResp(200, SAMPLE_HTML)
# Any other path fails to ensure the code tries the intended fallback
return DummyResp(404, "")
orig_client = scraping.httpx.AsyncClient
scraping.httpx.AsyncClient = DummyClient
try:
r = self.client.post(
f"/api/v1/households/{self.slug}/recipes/parse-from-url",
headers=self.headers,
json={"url": SAMPLE_URL},
)
assert r.status_code == 200, r.text
data = r.json()
assert "name" in data and isinstance(data.get("ingredients"), list)
# Assert we attempted the base URL then an AMP variant
urls_called = [u for (u, _h) in calls]
assert urls_called[0] == SAMPLE_URL
assert any("output=amp" in u for u in urls_called[1:])
finally:
scraping.httpx.AsyncClient = orig_client