From 7b6f4e2a3b38ca5e873c8345b48f53b62cebb511 Mon Sep 17 00:00:00 2001 From: jableader Date: Mon, 20 Oct 2025 00:12:16 +1100 Subject: [PATCH] Squashed commit of the following: commit 21a17b771743b23ee41d11a90ed8fdc3433468ce Author: jableader Date: Mon Oct 20 00:12:02 2025 +1100 Completed tooling improvements, fixed remaining errors commit 7db48e222e3aa1065c326197c33ba6439720f65a Author: jableader Date: Sun Oct 19 22:05:37 2025 +1100 autoformat commit 5705ce24b64c2aa6f0b9426730a479165fa97e2a Author: jableader Date: Sun Oct 19 22:05:29 2025 +1100 tooling changes commit f0a6b2fd147bb86b484927afd57b9ba0ac07bf47 Author: jableader Date: Sun Oct 19 21:25:49 2025 +1100 Plan --- .env.example | 11 ++ .pre-commit-config.yaml | 29 ++++ CONTRIBUTING.md | 186 ++++++++++++++++++++++ Makefile | 63 ++++++++ README.md | 89 +++++++++-- api/auth.py | 8 +- api/deps.py | 5 +- api/meals.py | 100 +++++++++--- api/openapi.py | 4 +- api/persons.py | 9 +- api/recipes.py | 9 +- api/shopping.py | 84 ++++++++-- common.py | 4 +- dev-requirements.txt | 10 ++ ingredients/repository.py | 16 +- main.py | 12 +- meals/repository.py | 14 +- openapi.json | 14 ++ persons/repository.py | 6 +- products/repository.py | 10 +- pyproject.toml | 5 - recipes/repository.py | 24 +-- settings.py | 1 + shopping/repository.py | 16 +- tests/test_health_and_location_headers.py | 2 + tests/test_main.py | 15 +- tests/test_shopping_api.py | 61 +++++++ 27 files changed, 703 insertions(+), 104 deletions(-) create mode 100644 .env.example create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 dev-requirements.txt create mode 100644 tests/test_shopping_api.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..133269f --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Database configuration +DOOF_DB=./data/doof.sqlite + +# Production flag (set to "true" in production) +DOOF_PROD=false + +# Frontend development server URL (used when DOOF_PROD=false) +FRONTEND_DEV_URL=http://localhost:8080/ + +# Server port (used in containerized deployments) +# DOOF_PORT=8000 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2f999d7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + # Run the linter + - id: ruff + args: [--fix] + # Run the formatter + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: + - pydantic==2.9.2 + - fastapi==0.115.0 + - httpx==0.27.2 + - aiosqlite==0.20.0 + args: [--config-file=pyproject.toml] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0d0bfe3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,186 @@ +# Contributing to Doof Backend + +Thank you for your interest in contributing! This document provides guidelines and workflows for contributing to the project. + +## Getting Started + +### First-time setup + +1. Clone the repository +2. Run the setup script: + ```bash + make install + ``` + This will: + - Create a virtual environment in `.venv` + - Install all dependencies + - Install pre-commit hooks + +3. Activate the virtual environment: + ```bash + source .venv/bin/activate # On Windows: .venv\Scripts\activate + ``` + +4. Run tests to verify everything works: + ```bash + make test + ``` + +## Development Workflow + +### Running the application + +Start the development server: +```bash +make dev +``` + +The server will run at `http://localhost:8000` with auto-reload enabled. + +### Code Quality + +Before committing, ensure your code passes all checks: + +```bash +make all-checks +``` + +This runs: +- `ruff check` - Linting +- `ruff format --check` - Format verification +- `mypy` - Type checking +- `pytest` - Tests +- OpenAPI export + +Individual checks can be run separately: +```bash +make format # Auto-format code +make lint # Lint code +make typecheck # Type check +make test # Run tests +``` + +### Pre-commit Hooks + +Pre-commit hooks are automatically installed with `make install`. They run on every commit to: +- Format code with ruff +- Lint code with ruff +- Type check with mypy +- Fix trailing whitespace +- Ensure files end with a newline + +If pre-commit fails, fix the issues and commit again. + +To run pre-commit manually: +```bash +pre-commit run --all-files +``` + +## Code Style + +- **Formatting**: We use [ruff](https://docs.astral.sh/ruff/) for both linting and formatting +- **Line length**: 100 characters (configured in `pyproject.toml`) +- **Type hints**: Use type hints where possible; mypy is configured for basic type checking +- **Imports**: Organized automatically by ruff (isort-style) + +## Testing + +- All tests live in the `tests/` directory +- We use `pytest` as the test runner +- Tests use in-memory SQLite for fast, hermetic execution +- HTTP requests are mocked using fixtures in `tests/httpx_mocks.py` + +### Writing tests + +```python +import unittest +from db import connect, create + +class TestMyFeature(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + # Add test data if needed + return await super().asyncSetUp() + + async def asyncTearDown(self): + await self.conn.close() + return await super().asyncTearDown() + + async def test_something(self): + # Your test here + pass +``` + +Run tests: +```bash +make test +``` + +## Project Structure + +``` +. +├── api/ # FastAPI routers and endpoints +├── ingredients/ # Ingredient models and repository +├── meals/ # Meal models, repository, and service logic +├── persons/ # Person models and repository +├── products/ # Product models, repository, and scrapers +├── recipes/ # Recipe models, repository, and scraping +├── shopping/ # Shopping list models and repository +├── scripts/ # Utility scripts (e.g., OpenAPI export) +├── tests/ # Test suite +├── main.py # FastAPI application entry point +├── db.py # Database connection and schema bootstrap +└── settings.py # Configuration and environment variables +``` + +## Environment Variables + +Copy `.env.example` to `.env` and customize: + +```bash +cp .env.example .env +``` + +Key variables: +- `DOOF_DB` - Database file path (default: `./data/doof.sqlite`) +- `DOOF_PROD` - Production mode flag (default: `false`) +- `FRONTEND_DEV_URL` - Frontend dev server URL for reverse proxy + +## OpenAPI Schema + +Export the OpenAPI schema: +```bash +make openapi +``` + +This generates `openapi.json` from the FastAPI application. + +## Commit Guidelines + +- Write clear, descriptive commit messages +- Keep commits focused and atomic +- Reference issue numbers if applicable +- Pre-commit hooks will enforce code quality + +Example commit message: +``` +Add meal duplication detection + +- Implement get_duplicates function in meals service +- Add test coverage for duplicate detection +- Update API endpoint to return duplicates + +Fixes #123 +``` + +## Need Help? + +- Check the [README.md](README.md) for basic setup and usage +- Review [tooling-spec.md](tooling-spec.md) for tooling details +- Open an issue for bugs or feature requests + +## License + +By contributing, you agree that your contributions will be licensed under the same license as the project. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..989688a --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +.PHONY: help install format lint typecheck test openapi dev all-checks clean + +# Use activated environment if available, otherwise use .venv explicitly +PYTHON := $(shell command -v python 2>/dev/null || echo .venv/bin/python) +PIP := $(shell command -v pip 2>/dev/null || echo .venv/bin/pip) + +help: + @echo "Available targets:" + @echo " install - Create venv and install dependencies" + @echo " format - Format code with ruff" + @echo " lint - Lint code with ruff" + @echo " typecheck - Type check with mypy" + @echo " test - Run tests with pytest" + @echo " openapi - Export OpenAPI schema" + @echo " dev - Run development server" + @echo " all-checks - Run all quality checks" + @echo " clean - Remove venv and caches" + @echo "" + @echo "Note: Make sure to activate your venv first: source .venv/bin/activate" + +install: + @echo "Creating virtual environment..." + python3 -m venv .venv + @echo "Installing dependencies..." + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -r requirements.txt + .venv/bin/pip install -r dev-requirements.txt + @echo "Installing pre-commit hooks..." + .venv/bin/pre-commit install + @echo "Done! Activate with: source .venv/bin/activate" + +format: + $(PYTHON) -m ruff format . + +lint: + $(PYTHON) -m ruff check . + +typecheck: + $(PYTHON) -m mypy . + +test: + $(PYTHON) -m pytest -q + +openapi: + $(PYTHON) scripts/export_openapi.py + +dev: + $(PYTHON) -m uvicorn main:app --reload + +all-checks: lint typecheck test + @echo "Running format check..." + $(PYTHON) -m ruff format --check . + @echo "Exporting OpenAPI schema..." + $(PYTHON) scripts/export_openapi.py + @echo "" + @echo "✓ All checks passed!" + +clean: + rm -rf .venv + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type d -name .pytest_cache -exec rm -rf {} + + find . -type d -name .mypy_cache -exec rm -rf {} + + find . -type d -name .ruff_cache -exec rm -rf {} + diff --git a/README.md b/README.md index 9b67961..ba6fbb4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,27 @@ Meal planner backend +## Quickstart + +First time setup: +```bash +make install +``` + +Run the development server: +```bash +make dev +``` + +Run tests: +```bash +make test +``` + +Run all quality checks (lint, typecheck, test, format check, OpenAPI export): +```bash +make all-checks +``` + ## Structure - `main.py`: FastAPI app with all HTTP endpoints. @@ -15,38 +37,79 @@ Meal planner backend ## Getting started -Install packages +### Manual setup (alternative to make install) + +Create and activate virtual environment: +```bash +python3 -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate ``` + +Install packages: +```bash pip install -r ./requirements.txt +pip install -r ./dev-requirements.txt ``` -Run API (dev) -``` -uvicorn main:app --reload +Install pre-commit hooks: +```bash +pip install pre-commit +pre-commit install ``` -Run tests +### Running the application + +Run API (dev): +```bash +make dev +# or: uvicorn main:app --reload ``` -pytest -q + +Run tests: +```bash +make test +# or: pytest -q ``` +### Available Make targets + +- `make install` - Create venv and install all dependencies +- `make dev` - Run development server +- `make test` - Run tests +- `make format` - Format code with ruff +- `make lint` - Lint code with ruff +- `make typecheck` - Type check with mypy +- `make openapi` - Export OpenAPI schema +- `make all-checks` - Run all quality checks +- `make clean` - Remove venv and caches + ## Tooling This repo includes baseline configs in `pyproject.toml`: -- black (format) -- ruff (lint) +- ruff (format and lint) - mypy (type check) -Optional commands (install these locally first): -``` -ruff check . -ruff format . -mypy . +Pre-commit hooks are configured to run: +- ruff (lint + format) +- mypy +- trailing whitespace fixer +- end-of-file fixer + +Quality checks (run locally): +```bash +make format # Format code +make lint # Lint code +make typecheck # Type check +make all-checks # Run all checks ``` ## Environment variables +Copy `.env.example` to `.env` and customize as needed: + - DOOF_DB: Path to sqlite database (default: `./data/doof.sqlite`) +- DOOF_PROD: Set to `true` in production (default: `false`) +- FRONTEND_DEV_URL: Frontend dev server URL for reverse proxy (default: `http://localhost:8080/`) - DOOF_PORT: Port the server listens on when containerized; align Dockerfile `EXPOSE` accordingly. ## OpenAPI schema diff --git a/api/auth.py b/api/auth.py index 8288fb6..3784dfb 100644 --- a/api/auth.py +++ b/api/auth.py @@ -21,7 +21,11 @@ class LoginBody(ApiModel): summary="Login and set user_id cookie", responses={ 200: {"model": persons.Person, "description": "Successful Response"}, - 404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}} + 404: { + "model": ProblemDetails, + "description": "Person not found", + "content": {"application/problem+json": {}}, + }, }, ) async def login( @@ -29,7 +33,7 @@ async def login( data: LoginBody, response: Response, conn: aiosqlite.Connection = Depends(get_db), -) -> persons.Person: +) -> persons.Person | Response: person = await persons.get_by_name(conn, data.username) if not person: return error_response(request, 404, "Person not found") diff --git a/api/deps.py b/api/deps.py index 6154f43..3854355 100644 --- a/api/deps.py +++ b/api/deps.py @@ -38,8 +38,11 @@ async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]: async def cookie_person( - user_id: Annotated[int, Cookie(alias="user_id")], conn: aiosqlite.Connection = Depends(get_db) + user_id: Optional[int] = Cookie(None, alias="user_id"), + conn: aiosqlite.Connection = Depends(get_db), ) -> Optional[persons.Person]: + if user_id is None: + return None return await persons.get_by_id(conn, user_id) diff --git a/api/meals.py b/api/meals.py index 83197e5..8caf666 100644 --- a/api/meals.py +++ b/api/meals.py @@ -15,7 +15,9 @@ from common import ProblemDetails router = APIRouter(prefix="/meals", tags=["meals"]) -@router.get("/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range") +@router.get( + "/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range" +) async def get_upcoming_meals( date_from: datetime.datetime = Query(..., alias="from"), to: datetime.datetime = Query(...), @@ -40,8 +42,19 @@ async def get_upcoming_meals( return result -@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id", - responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) +@router.get( + "/{meal_id}", + response_model=meals.Meal, + operation_id="getMeal", + summary="Get a meal by id", + responses={ + 404: { + "model": ProblemDetails, + "description": "Meal not found", + "content": {"application/problem+json": {}}, + } + }, +) async def get_meal( meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal | Response: @@ -52,10 +65,24 @@ async def get_meal( return meal -@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal", - responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}}) +@router.post( + "", + response_model=meals.Meal, + operation_id="createMeal", + summary="Create a new meal", + responses={ + 400: { + "model": ProblemDetails, + "description": "Validation error", + "content": {"application/problem+json": {}}, + } + }, +) async def create_meal( - meal: meals.Meal, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db) + meal: meals.Meal, + request: Request, + response: Response, + conn: aiosqlite.Connection = Depends(get_db), ) -> meals.Meal | Response: validation_response = validate_meal(meal, request) if validation_response: @@ -66,11 +93,24 @@ async def create_meal( return meal -@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal", - responses={ - 400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}, - 404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}, - }) +@router.put( + "/{meal_id}", + response_model=meals.Meal, + operation_id="updateMeal", + summary="Update an existing meal", + responses={ + 400: { + "model": ProblemDetails, + "description": "Validation error", + "content": {"application/problem+json": {}}, + }, + 404: { + "model": ProblemDetails, + "description": "Meal not found", + "content": {"application/problem+json": {}}, + }, + }, +) async def update_meal( meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal | Response: @@ -91,11 +131,24 @@ async def update_meal( return await get_meal(meal_id, request, conn) -@router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed", - responses={ - 400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}, - 404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}, - }) +@router.post( + "/{meal_id}/consumed", + response_model=meals.Meal, + operation_id="markMealConsumed", + summary="Mark a meal as consumed", + responses={ + 400: { + "model": ProblemDetails, + "description": "Validation error", + "content": {"application/problem+json": {}}, + }, + 404: { + "model": ProblemDetails, + "description": "Meal not found", + "content": {"application/problem+json": {}}, + }, + }, +) async def mark_consumed( meal_id: int, request: Request, @@ -116,8 +169,19 @@ async def mark_consumed( return meal -@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal", - responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) +@router.delete( + "/{meal_id}", + response_model=meals.Meal, + operation_id="deleteMeal", + summary="Delete a meal", + responses={ + 404: { + "model": ProblemDetails, + "description": "Meal not found", + "content": {"application/problem+json": {}}, + } + }, +) async def delete_meal( meal_id: int, request: Request, diff --git a/api/openapi.py b/api/openapi.py index e8e3332..ad3163d 100644 --- a/api/openapi.py +++ b/api/openapi.py @@ -45,7 +45,9 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None: { "description": "Validation Error", "content": { - "application/problem+json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}}, + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/ProblemDetails"} + }, "application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}}, }, }, diff --git a/api/persons.py b/api/persons.py index 0b39638..e1eabc5 100644 --- a/api/persons.py +++ b/api/persons.py @@ -23,15 +23,10 @@ router = APIRouter(prefix="/persons", tags=["persons"]) "content": { "application/json": { "example": { - "items": [ - { - "id": 1, - "name": "Ada Lovelace" - } - ], + "items": [{"id": 1, "name": "Ada Lovelace"}], "nextCursor": "2", "prevCursor": "0", - "total": 1 + "total": 1, } } }, diff --git a/api/recipes.py b/api/recipes.py index 4746bcd..849cf55 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -28,7 +28,10 @@ router = APIRouter(prefix="/recipes", tags=["recipes"]) }, ) async def parse_recipe_handler( - url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person) + url: str, + request: Request, + conn: aiosqlite.Connection = Depends(get_db), + person=Depends(cookie_person), ) -> recipes.Recipe | Response: parsed = await recipes.parse_recipe(conn, person, url) if not parsed: @@ -100,12 +103,12 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci "link": "https://example.com/recipes/1", "serves": 4, "imageUrls": [], - "ingredients": [] + "ingredients": [], } ], "nextCursor": "2", "prevCursor": "0", - "total": 1 + "total": 1, } } }, diff --git a/api/shopping.py b/api/shopping.py index e3367aa..3c375a5 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -33,7 +33,9 @@ class CurrentShoppingList(ApiModel): operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list", ) -async def get_current_shopping_list(conn: aiosqlite.Connection = Depends(get_db)) -> CurrentShoppingList: +async def get_current_shopping_list( + conn: aiosqlite.Connection = Depends(get_db), +) -> CurrentShoppingList: ( outstanding_requests, purchased_requests, @@ -76,9 +78,22 @@ class PurchasedShoppingList(ApiModel): recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) -@router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id", - responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}}) -async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | Response: +@router.get( + "/{list_id}", + response_model=PurchasedShoppingList, + operation_id="getShoppingList", + summary="Get a purchased shopping list by id", + responses={ + 404: { + "model": ProblemDetails, + "description": "Shopping list not found", + "content": {"application/problem+json": {}}, + } + }, +) +async def get_shopping_list( + list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db) +) -> PurchasedShoppingList | Response: shopping_list = await shopping.load_shopping_list(conn, list_id) if not shopping_list: return error_response(request, 404, "Shopping list not found") @@ -94,13 +109,43 @@ async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Conn ) -@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list") -async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList: +@router.post( + "", + response_model=PurchasedShoppingList, + operation_id="purchaseIngredients", + summary="Purchase ingredients for a shopping list", + responses={ + 400: { + "model": ProblemDetails, + "description": "Validation error", + "content": {"application/problem+json": {}}, + }, + 401: { + "model": ProblemDetails, + "description": "Unauthorized (invalid or unknown user)", + "content": {"application/problem+json": {}}, + }, + }, +) +async def purchase_ingredients( + shopping_list: shopping.ShoppingList, + request: Request, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> PurchasedShoppingList | Response: + # Ensure the caller is authenticated and maps to a known user + if not person: + return error_response(request, 401, "Unauthorized") + + # Attach purchaser to ensure purchased_by_id is set via BaseLinkedModel shopping_list = shopping.ShoppingList( purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name ) - - await shopping.purchase(conn, shopping_list) + try: + await shopping.purchase(conn, shopping_list) + except ValueError as e: + # Map domain validation errors to a proper Problem Details response + return error_response(request, 400, str(e)) result = PurchasedShoppingList(list=shopping_list) await shopping.to_lookups( conn, @@ -112,13 +157,27 @@ async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosq return result -@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests") -async def get_my_shopping_list(conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]: +@router.get( + "/current/me/ingredients", + operation_id="getMyShoppingList", + summary="Get my outstanding ingredient requests", +) +async def get_my_shopping_list( + conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) +) -> List[ingredients.Ingredient]: return await shopping.get_persons_requests(conn, person.id) -@router.post("/current/me/ingredients", operation_id="syncMyShoppingList", summary="Sync my outstanding ingredient requests") -async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]: +@router.post( + "/current/me/ingredients", + operation_id="syncMyShoppingList", + summary="Sync my outstanding ingredient requests", +) +async def sync_my_shopping_list( + requests: List[ingredients.Ingredient], + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> List[ingredients.Ingredient]: def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool: return a.id == b.id or a.line == b.line @@ -167,6 +226,7 @@ async def request_meal( response = await shopping.request(conn, person, meal=meal) return response + class Ok(ApiModel): ok: bool = True diff --git a/common.py b/common.py index 2fd2552..faf3b9a 100644 --- a/common.py +++ b/common.py @@ -29,9 +29,7 @@ class BaseLinkedModel(ApiModel): if id_key in data: # If the id_key already exists, ensure it matches the value's id if data[id_key] != value.id: - raise ValueError( - f"ID mismatch for {key}: {data[id_key]} != {value.id}" - ) + raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}") else: # If the id_key does not exist, set it to the value's id data[id_key] = value.id diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..57b6199 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,10 @@ +# Development dependencies +# Install with: pip install -r dev-requirements.txt + +# Code quality tools +ruff>=0.6.9 +mypy>=1.11.2 +pre-commit>=3.8.0 + +# Testing (pytest already in main requirements via fastapi dev deps) +pytest>=7.0 diff --git a/ingredients/repository.py b/ingredients/repository.py index 9ece8c6..10fe577 100644 --- a/ingredients/repository.py +++ b/ingredients/repository.py @@ -23,7 +23,9 @@ async def create(conn): );""" ) # Useful indexes - await conn.execute("CREATE INDEX IF NOT EXISTS idx_ingredient_recipe_id ON Ingredient(recipe_id);") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_ingredient_recipe_id ON Ingredient(recipe_id);" + ) await conn.execute("CREATE INDEX IF NOT EXISTS idx_ingredient_meal_id ON Ingredient(meal_id);") @@ -59,7 +61,7 @@ async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient async with conn.execute( f""" - SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient + SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient LEFT JOIN Product ON Ingredient.product_id = Product.id WHERE Ingredient.id = ? """, @@ -81,7 +83,7 @@ async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[I async with conn.execute( f""" - SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient + SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient LEFT JOIN Product ON Ingredient.product_id = Product.id WHERE recipe_id = ? """, @@ -96,7 +98,9 @@ async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[I ) -async def find_ingredients_by_recipe_ids(conn, recipe_ids: List[int]) -> dict[int, List[Ingredient]]: +async def find_ingredients_by_recipe_ids( + conn, recipe_ids: List[int] +) -> dict[int, List[Ingredient]]: """Fetch ingredients for many recipes in one query. Returns recipe_id -> [Ingredient].""" if not recipe_ids: return {} @@ -104,7 +108,7 @@ async def find_ingredients_by_recipe_ids(conn, recipe_ids: List[int]) -> dict[in ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS] product_cols = [f"product.{key}" for key in Product.KEYS] query = f""" - SELECT {','.join(ingredient_cols + product_cols)} + SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient AS ingredient LEFT JOIN Product AS product ON ingredient.product_id = product.id WHERE ingredient.recipe_id IN ({placeholders}) @@ -130,7 +134,7 @@ async def find_ingredients_by_meal_id(conn, meal_id: int) -> AsyncIterator[Ingre async with conn.execute( f""" - SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient + SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient LEFT JOIN Product ON Ingredient.product_id = Product.id WHERE meal_id = ? """, diff --git a/main.py b/main.py index 672e720..e8ce386 100644 --- a/main.py +++ b/main.py @@ -79,7 +79,9 @@ async def validation_exc_handler(request: Request, exc: Exception): errors=errors, ) return JSONResponse( - content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json" + content=body.model_dump(by_alias=True), + status_code=422, + media_type="application/problem+json", ) @@ -97,7 +99,9 @@ async def request_validation_exc_handler(request: Request, exc: Exception): errors=errors, ) return JSONResponse( - content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json" + content=body.model_dump(by_alias=True), + status_code=422, + media_type="application/problem+json", ) @@ -110,7 +114,9 @@ async def healthz() -> HealthStatus: def create_app() -> FastAPI: - app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan) + app = FastAPI( + title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan + ) # OpenAPI augmentation extend_with_problem_and_cookie_auth(app) diff --git a/meals/repository.py b/meals/repository.py index aa9ca57..8d57ed2 100644 --- a/meals/repository.py +++ b/meals/repository.py @@ -39,7 +39,9 @@ async def create(conn): );""" ) # Useful indexes - await conn.execute("CREATE INDEX IF NOT EXISTS idx_meal_participants_meal_role ON MealParticipant(meal_id, role);") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_meal_participants_meal_role ON MealParticipant(meal_id, role);" + ) await conn.execute( """ @@ -53,7 +55,9 @@ async def create(conn): ) # Index for faster lookup of recipes by meal - await conn.execute("CREATE INDEX IF NOT EXISTS idx_meal_recipes_meal_id ON MealRecipe(meal_id);") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_meal_recipes_meal_id ON MealRecipe(meal_id);" + ) async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str): @@ -123,7 +127,7 @@ async def insert_meal(conn, meal: Meal): async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]: async with conn.execute( f""" - SELECT {','.join(Meal.KEYS)} FROM Meal + SELECT {",".join(Meal.KEYS)} FROM Meal WHERE id = ? LIMIT 1 """, @@ -144,7 +148,7 @@ async def find_upcoming_meals_by_date_range( ) -> AsyncIterator[Meal]: async with conn.execute( f""" - SELECT {','.join(Meal.KEYS)} FROM Meal + SELECT {",".join(Meal.KEYS)} FROM Meal WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL """, (start, end), @@ -249,7 +253,7 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None: async def load_recipes(conn, meal: Meal) -> None: async with conn.execute( f""" - SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings + SELECT {",".join(Recipe.KEYS)}, MealRecipe.servings as requested_servings FROM Recipe JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id WHERE MealRecipe.meal_id = ? diff --git a/openapi.json b/openapi.json index 8d4b9c6..fb4b492 100644 --- a/openapi.json +++ b/openapi.json @@ -875,6 +875,20 @@ } } }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "401": { + "description": "Unauthorized (invalid or unknown user)", + "content": { + "application/problem+json": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "422": { "description": "Validation Error", "content": { diff --git a/persons/repository.py b/persons/repository.py index 35436a0..edaf4e8 100644 --- a/persons/repository.py +++ b/persons/repository.py @@ -148,7 +148,9 @@ async def count_by_name(conn, name: str) -> int: return int(row[0]) if row else 0 -async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[str] = None) -> Optional[str]: +async def compute_prev_cursor( + conn, first_id: int, limit: int, name: Optional[str] = None +) -> Optional[str]: """Compute a prevCursor string for paginated persons, respecting optional name LIKE filter.""" if limit <= 0: return None @@ -161,6 +163,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st LIMIT ? """ from typing import Any + params: tuple[Any, ...] = (f"%{name}%", first_id, limit) else: query = """ @@ -171,6 +174,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st LIMIT ? """ from typing import Any + params = (first_id, limit) async with conn.execute(query, params) as c: diff --git a/products/repository.py b/products/repository.py index 285b890..6058dbf 100644 --- a/products/repository.py +++ b/products/repository.py @@ -35,7 +35,7 @@ async def create(conn): async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]: async with conn.execute( f""" - SELECT {','.join(Product.KEYS)} FROM Product + SELECT {",".join(Product.KEYS)} FROM Product WHERE id IN ( SELECT food_item_id FROM ProductTag WHERE tag = ? @@ -50,7 +50,7 @@ async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]: async def find_product_by_id(conn, product_id: int) -> Optional[Product]: async with conn.execute( f""" - SELECT {','.join(Product.KEYS)} FROM Product + SELECT {",".join(Product.KEYS)} FROM Product WHERE id = ? LIMIT 1 """, @@ -64,7 +64,7 @@ async def find_product_by_id(conn, product_id: int) -> Optional[Product]: async def find_product_by_key(conn, shop_code: str, product_id: str) -> Optional[Product]: async with conn.execute( f""" - SELECT {','.join(Product.KEYS)} FROM Product + SELECT {",".join(Product.KEYS)} FROM Product WHERE shop_code = ? AND product_id = ? LIMIT 1 """, @@ -84,8 +84,8 @@ async def insert_product(conn, product: Product, data: dict): async with conn.execute( f""" - INSERT INTO Product ({','.join(insert_keys)}, raw_data) - VALUES ({','.join(['?'] * len(insert_keys))}, ?) + INSERT INTO Product ({",".join(insert_keys)}, raw_data) + VALUES ({",".join(["?"] * len(insert_keys))}, ?) """, (*insert_values, json.dumps(data)), ) as cursor: diff --git a/pyproject.toml b/pyproject.toml index 9f4df9b..cae659d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,3 @@ -[tool.black] -line-length = 100 -target-version = ["py311"] -include = "\\.pyi?$" - [tool.ruff] line-length = 100 target-version = "py311" diff --git a/recipes/repository.py b/recipes/repository.py index 1a30a29..d517f2f 100644 --- a/recipes/repository.py +++ b/recipes/repository.py @@ -30,8 +30,12 @@ async def create(conn): );""" ) # Useful indexes for filtering/pagination - await conn.execute("CREATE INDEX IF NOT EXISTS idx_recipe_hidden_id ON Recipe(date_hidden, id);") - await conn.execute("CREATE INDEX IF NOT EXISTS idx_recipe_name_hidden_id ON Recipe(name, date_hidden, id);") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_recipe_hidden_id ON Recipe(date_hidden, id);" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_recipe_name_hidden_id ON Recipe(name, date_hidden, id);" + ) def _as_insert_field(recipe: Recipe, name: str): @@ -49,8 +53,8 @@ async def insert_recipe(conn, recipe: Recipe): actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert] insert_stmt = f""" - INSERT INTO Recipe ({','.join(fields_to_insert)}) - VALUES ({','.join(['?'] * len(fields_to_insert))}) + INSERT INTO Recipe ({",".join(fields_to_insert)}) + VALUES ({",".join(["?"] * len(fields_to_insert))}) """ async with conn.execute(insert_stmt, actual_values) as cursor: @@ -80,7 +84,7 @@ def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe: async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]: async with conn.execute( f""" - SELECT {','.join(Recipe.KEYS)} FROM Recipe + SELECT {",".join(Recipe.KEYS)} FROM Recipe WHERE id = ? LIMIT 1 """, @@ -94,7 +98,7 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]: async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]: async with conn.execute( f""" - SELECT {','.join(Recipe.KEYS)} FROM Recipe + SELECT {",".join(Recipe.KEYS)} FROM Recipe WHERE name LIKE ? AND date_hidden IS NULL """, (f"%{name}%",), @@ -106,7 +110,7 @@ async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]: async def get_all(conn) -> AsyncIterator[Recipe]: async with conn.execute( f""" - SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL + SELECT {",".join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL """ ) as cursor: async for row in cursor: @@ -123,7 +127,7 @@ async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncItera after = after_id if after_id is not None else -1 async with conn.execute( f""" - SELECT {','.join(Recipe.KEYS)} + SELECT {",".join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL AND id > ? ORDER BY id @@ -141,7 +145,7 @@ async def find_recipes_by_name_paged( after = after_id if after_id is not None else -1 async with conn.execute( f""" - SELECT {','.join(Recipe.KEYS)} + SELECT {",".join(Recipe.KEYS)} FROM Recipe WHERE name LIKE ? AND date_hidden IS NULL AND id > ? ORDER BY id @@ -172,6 +176,7 @@ async def compute_prev_cursor( LIMIT ? """ from typing import Any + params: tuple[Any, ...] = (f"%{name}%", first_id, limit) else: query = """ @@ -182,6 +187,7 @@ async def compute_prev_cursor( LIMIT ? """ from typing import Any + params = (first_id, limit) async with conn.execute(query, params) as c: diff --git a/settings.py b/settings.py index 778bb85..f17a199 100644 --- a/settings.py +++ b/settings.py @@ -4,6 +4,7 @@ Centralized runtime settings for the Doof backend. No external dependencies; reads from environment only so it can be imported anywhere (including tests) without side effects. """ + from __future__ import annotations import os diff --git a/shopping/repository.py b/shopping/repository.py index 01841b7..b8aeb58 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -33,8 +33,12 @@ async def create(conn): );""" ) # Useful indexes for queries - await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_list_id ON ShoppingListItem(list_id);") - await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_meal_id ON ShoppingListItem(meal_id);") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_shopping_item_list_id ON ShoppingListItem(list_id);" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_shopping_item_meal_id ON ShoppingListItem(meal_id);" + ) await conn.execute( "CREATE INDEX IF NOT EXISTS idx_shopping_item_person_null_list ON ShoppingListItem(person_id, ingredient_id) WHERE list_id IS NULL;" ) @@ -248,7 +252,7 @@ async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[S request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] select = f""" - SELECT {','.join(request_cols)} + SELECT {",".join(request_cols)} FROM ShoppingListItem """ @@ -270,7 +274,7 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]: shopping_list: Optional[ShoppingList] = None async with conn.execute( f""" - SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList + SELECT {",".join(ShoppingList.KEYS)} FROM ShoppingList WHERE id = ? LIMIT 1 """, @@ -293,9 +297,9 @@ async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ async with conn.execute( f""" - SELECT {','.join(ShoppingListItem.KEYS)} + SELECT {",".join(ShoppingListItem.KEYS)} FROM ShoppingListItem - WHERE meal_id IN ({','.join(['?'] * len(meal_ids))}) AND list_id IS NOT NULL + WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL """, meal_ids, ) as cursor: diff --git a/tests/test_health_and_location_headers.py b/tests/test_health_and_location_headers.py index 0d90d32..5fafe4d 100644 --- a/tests/test_health_and_location_headers.py +++ b/tests/test_health_and_location_headers.py @@ -27,9 +27,11 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase): pass main.app.dependency_overrides[main.get_db] = override_get_db + # Always act as an authenticated user for tests that require auth async def override_cookie_person(): return test_data.Persons.jacob + main.app.dependency_overrides[main.cookie_person] = override_cookie_person self.client = TestClient(main.app) return await super().asyncSetUp() diff --git a/tests/test_main.py b/tests/test_main.py index fc959a3..e9c7eee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -190,9 +190,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): } response = self.client.post("/api/v1/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn( - "Meal must have at least one recipe or ingredient", response.json()["title"] - ) + self.assertIn("Meal must have at least one recipe or ingredient", response.json()["title"]) def test_create_meal_invalid_duplicate_chefs(self): """Test creating a meal with duplicate chefs (should fail validation)""" @@ -275,6 +273,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): def test_delete_meal_not_found(self): """Test deleting a meal that doesn't exist""" + # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob @@ -569,6 +568,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_create_recipe_valid(self): """Test creating a valid recipe - currently fails due to auth dependency issues""" + # The authentication dependency injection isn't working properly in tests # This would require a more complex setup to properly mock FastAPI dependencies async def override_cookie_person(): @@ -605,6 +605,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_create_recipe_no_ingredients(self): """Test creating a recipe without ingredients - auth dependency issues prevent proper testing""" + # The authentication dependency injection isn't working properly in tests async def override_cookie_person(): return test_data.Persons.jacob @@ -631,6 +632,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_mark_consumed_invalid_timezone(self): """Test marking meal as consumed with invalid timezone""" + # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob @@ -664,6 +666,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_request_meal_not_found(self): """Test requesting a meal that doesn't exist""" + # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob @@ -682,6 +685,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_unrequest_meal_not_found(self): """Test unrequesting a meal that doesn't exist""" + # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob @@ -699,6 +703,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_get_my_shopping_list_empty(self): """Test getting empty shopping list when no items are requested""" + # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob @@ -755,6 +760,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_sync_my_shopping_list_empty_to_empty(self): """Test syncing empty list with empty current state""" + # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob @@ -1102,6 +1108,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): def test_sync_my_shopping_list_invalid_json(self): """Test sync_my_shopping_list with invalid JSON data""" + # Override the cookie_person dependency async def override_cookie_person(): return test_data.Persons.jacob @@ -1115,7 +1122,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): json=[ { "id": "not_a_number", # Invalid ID type - "name": "Test Ingredient" + "name": "Test Ingredient", # Missing required fields } ], diff --git a/tests/test_shopping_api.py b/tests/test_shopping_api.py new file mode 100644 index 0000000..62fd28a --- /dev/null +++ b/tests/test_shopping_api.py @@ -0,0 +1,61 @@ +import importlib +import unittest +from fastapi.testclient import TestClient + +from db import connect, create +import main +import tests.test_data as test_data + + +def reload_test_data(): + global test_data + test_data = importlib.reload(test_data) + + +class TestShoppingAPI(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + await test_data.create_test_data(self.conn) + reload_test_data() + + async def override_get_db(): + try: + yield self.conn + finally: + pass + + main.app.dependency_overrides[main.get_db] = override_get_db + self.client = TestClient(main.app) + return await super().asyncSetUp() + + async def asyncTearDown(self) -> None: + await self.conn.close() + main.app.dependency_overrides.clear() + return await super().asyncTearDown() + + def test_purchase_unauthorized_without_cookie(self): + # Do NOT override cookie_person; no cookie provided => 401 + body = {"storeName": "woolworths", "items": []} + resp = self.client.post("/api/v1/shopping", json=body) + assert resp.status_code == 401 + assert "application/problem+json" in resp.headers.get("content-type", "") + prob = resp.json() + assert prob.get("status") == 401 + assert prob.get("title") + + def test_purchase_validation_error_returns_problem(self): + # Override cookie_person to simulate authenticated user + async def override_cookie_person(): + return test_data.Persons.jacob + + main.app.dependency_overrides[main.cookie_person] = override_cookie_person + + # Empty items triggers domain validation error => 400 with Problem Details + body = {"storeName": "woolworths", "items": []} + resp = self.client.post("/api/v1/shopping", json=body) + assert resp.status_code == 400 + assert "application/problem+json" in resp.headers.get("content-type", "") + prob = resp.json() + assert prob.get("status") == 400 + assert "items" in prob.get("title", "").lower() or prob.get("title")