Compare commits
2 commits
5be4e89c4e
...
7b6f4e2a3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b6f4e2a3b | |||
| 5c33e9c2a4 |
29 changed files with 703 additions and 184 deletions
11
.env.example
Normal file
11
.env.example
Normal file
|
|
@ -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
|
||||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
- name: Install deps
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install black ruff mypy
|
|
||||||
- name: Lint
|
|
||||||
run: |
|
|
||||||
ruff check .
|
|
||||||
- name: Type check
|
|
||||||
run: |
|
|
||||||
mypy .
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
python -m unittest -q
|
|
||||||
50
.github/workflows/openapi.yml
vendored
50
.github/workflows/openapi.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
name: OpenAPI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, openapi, '**/openapi' ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
schema:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.10'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Export OpenAPI schema
|
|
||||||
run: |
|
|
||||||
python scripts/export_openapi.py
|
|
||||||
|
|
||||||
- name: Set up Node for schema tools
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Lint schema with Spectral
|
|
||||||
run: npx -y @stoplight/spectral-cli lint openapi.json
|
|
||||||
|
|
||||||
- name: Compare with baseline if present
|
|
||||||
run: |
|
|
||||||
if [ -f openapi-baseline.json ]; then \
|
|
||||||
npx -y openapi-diff --fail-on-changed --fail-on-incompatible openapi-baseline.json openapi.json; \
|
|
||||||
else \
|
|
||||||
echo "No baseline file found. Skipping diff."; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload schema artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: openapi-schema
|
|
||||||
path: openapi.json
|
|
||||||
29
.pre-commit-config.yaml
Normal file
29
.pre-commit-config.yaml
Normal file
|
|
@ -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
|
||||||
186
CONTRIBUTING.md
Normal file
186
CONTRIBUTING.md
Normal file
|
|
@ -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.
|
||||||
63
Makefile
Normal file
63
Makefile
Normal file
|
|
@ -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 {} +
|
||||||
89
README.md
89
README.md
|
|
@ -1,5 +1,27 @@
|
||||||
Meal planner backend
|
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
|
## Structure
|
||||||
|
|
||||||
- `main.py`: FastAPI app with all HTTP endpoints.
|
- `main.py`: FastAPI app with all HTTP endpoints.
|
||||||
|
|
@ -15,38 +37,79 @@ Meal planner backend
|
||||||
|
|
||||||
## Getting started
|
## 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 ./requirements.txt
|
||||||
|
pip install -r ./dev-requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Run API (dev)
|
Install pre-commit hooks:
|
||||||
```
|
```bash
|
||||||
uvicorn main:app --reload
|
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
|
## Tooling
|
||||||
|
|
||||||
This repo includes baseline configs in `pyproject.toml`:
|
This repo includes baseline configs in `pyproject.toml`:
|
||||||
- black (format)
|
- ruff (format and lint)
|
||||||
- ruff (lint)
|
|
||||||
- mypy (type check)
|
- mypy (type check)
|
||||||
|
|
||||||
Optional commands (install these locally first):
|
Pre-commit hooks are configured to run:
|
||||||
```
|
- ruff (lint + format)
|
||||||
ruff check .
|
- mypy
|
||||||
ruff format .
|
- trailing whitespace fixer
|
||||||
mypy .
|
- 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
|
## Environment variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and customize as needed:
|
||||||
|
|
||||||
- DOOF_DB: Path to sqlite database (default: `./data/doof.sqlite`)
|
- 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.
|
- DOOF_PORT: Port the server listens on when containerized; align Dockerfile `EXPOSE` accordingly.
|
||||||
|
|
||||||
## OpenAPI schema
|
## OpenAPI schema
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ class LoginBody(ApiModel):
|
||||||
summary="Login and set user_id cookie",
|
summary="Login and set user_id cookie",
|
||||||
responses={
|
responses={
|
||||||
200: {"model": persons.Person, "description": "Successful Response"},
|
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(
|
async def login(
|
||||||
|
|
@ -29,7 +33,7 @@ async def login(
|
||||||
data: LoginBody,
|
data: LoginBody,
|
||||||
response: Response,
|
response: Response,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
) -> persons.Person:
|
) -> persons.Person | Response:
|
||||||
person = await persons.get_by_name(conn, data.username)
|
person = await persons.get_by_name(conn, data.username)
|
||||||
if not person:
|
if not person:
|
||||||
return error_response(request, 404, "Person not found")
|
return error_response(request, 404, "Person not found")
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,11 @@ async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
||||||
|
|
||||||
|
|
||||||
async def cookie_person(
|
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]:
|
) -> Optional[persons.Person]:
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
return await persons.get_by_id(conn, user_id)
|
return await persons.get_by_id(conn, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
96
api/meals.py
96
api/meals.py
|
|
@ -15,7 +15,9 @@ from common import ProblemDetails
|
||||||
router = APIRouter(prefix="/meals", tags=["meals"])
|
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(
|
async def get_upcoming_meals(
|
||||||
date_from: datetime.datetime = Query(..., alias="from"),
|
date_from: datetime.datetime = Query(..., alias="from"),
|
||||||
to: datetime.datetime = Query(...),
|
to: datetime.datetime = Query(...),
|
||||||
|
|
@ -40,8 +42,19 @@ async def get_upcoming_meals(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id",
|
@router.get(
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
"/{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(
|
async def get_meal(
|
||||||
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal | Response:
|
) -> meals.Meal | Response:
|
||||||
|
|
@ -52,10 +65,24 @@ async def get_meal(
|
||||||
return meal
|
return meal
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
|
@router.post(
|
||||||
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
"",
|
||||||
|
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(
|
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:
|
) -> meals.Meal | Response:
|
||||||
validation_response = validate_meal(meal, request)
|
validation_response = validate_meal(meal, request)
|
||||||
if validation_response:
|
if validation_response:
|
||||||
|
|
@ -66,11 +93,24 @@ async def create_meal(
|
||||||
return meal
|
return meal
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal",
|
@router.put(
|
||||||
|
"/{meal_id}",
|
||||||
|
response_model=meals.Meal,
|
||||||
|
operation_id="updateMeal",
|
||||||
|
summary="Update an existing meal",
|
||||||
responses={
|
responses={
|
||||||
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
|
400: {
|
||||||
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
|
"model": ProblemDetails,
|
||||||
})
|
"description": "Validation error",
|
||||||
|
"content": {"application/problem+json": {}},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"model": ProblemDetails,
|
||||||
|
"description": "Meal not found",
|
||||||
|
"content": {"application/problem+json": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def update_meal(
|
async def update_meal(
|
||||||
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal | Response:
|
) -> meals.Meal | Response:
|
||||||
|
|
@ -91,11 +131,24 @@ async def update_meal(
|
||||||
return await get_meal(meal_id, request, conn)
|
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",
|
@router.post(
|
||||||
|
"/{meal_id}/consumed",
|
||||||
|
response_model=meals.Meal,
|
||||||
|
operation_id="markMealConsumed",
|
||||||
|
summary="Mark a meal as consumed",
|
||||||
responses={
|
responses={
|
||||||
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
|
400: {
|
||||||
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
|
"model": ProblemDetails,
|
||||||
})
|
"description": "Validation error",
|
||||||
|
"content": {"application/problem+json": {}},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"model": ProblemDetails,
|
||||||
|
"description": "Meal not found",
|
||||||
|
"content": {"application/problem+json": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def mark_consumed(
|
async def mark_consumed(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -116,8 +169,19 @@ async def mark_consumed(
|
||||||
return meal
|
return meal
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal",
|
@router.delete(
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
"/{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(
|
async def delete_meal(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
||||||
{
|
{
|
||||||
"description": "Validation Error",
|
"description": "Validation Error",
|
||||||
"content": {
|
"content": {
|
||||||
"application/problem+json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
"application/problem+json": {
|
||||||
|
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
|
||||||
|
},
|
||||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,10 @@ router = APIRouter(prefix="/persons", tags=["persons"])
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"example": {
|
||||||
"items": [
|
"items": [{"id": 1, "name": "Ada Lovelace"}],
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Ada Lovelace"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"nextCursor": "2",
|
"nextCursor": "2",
|
||||||
"prevCursor": "0",
|
"prevCursor": "0",
|
||||||
"total": 1
|
"total": 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def parse_recipe_handler(
|
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:
|
) -> recipes.Recipe | Response:
|
||||||
parsed = await recipes.parse_recipe(conn, person, url)
|
parsed = await recipes.parse_recipe(conn, person, url)
|
||||||
if not parsed:
|
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",
|
"link": "https://example.com/recipes/1",
|
||||||
"serves": 4,
|
"serves": 4,
|
||||||
"imageUrls": [],
|
"imageUrls": [],
|
||||||
"ingredients": []
|
"ingredients": [],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nextCursor": "2",
|
"nextCursor": "2",
|
||||||
"prevCursor": "0",
|
"prevCursor": "0",
|
||||||
"total": 1
|
"total": 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ class CurrentShoppingList(ApiModel):
|
||||||
operation_id="getCurrentShoppingList",
|
operation_id="getCurrentShoppingList",
|
||||||
summary="Get the current aggregated shopping list",
|
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,
|
outstanding_requests,
|
||||||
purchased_requests,
|
purchased_requests,
|
||||||
|
|
@ -76,9 +78,22 @@ class PurchasedShoppingList(ApiModel):
|
||||||
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
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",
|
@router.get(
|
||||||
responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}})
|
"/{list_id}",
|
||||||
async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | Response:
|
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)
|
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||||
if not shopping_list:
|
if not shopping_list:
|
||||||
return error_response(request, 404, "Shopping list not found")
|
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")
|
@router.post(
|
||||||
async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList:
|
"",
|
||||||
|
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(
|
shopping_list = shopping.ShoppingList(
|
||||||
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
|
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
await shopping.purchase(conn, shopping_list)
|
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)
|
result = PurchasedShoppingList(list=shopping_list)
|
||||||
await shopping.to_lookups(
|
await shopping.to_lookups(
|
||||||
conn,
|
conn,
|
||||||
|
|
@ -112,13 +157,27 @@ async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosq
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests")
|
@router.get(
|
||||||
async def get_my_shopping_list(conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
|
"/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)
|
return await shopping.get_persons_requests(conn, person.id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/current/me/ingredients", operation_id="syncMyShoppingList", summary="Sync my outstanding ingredient requests")
|
@router.post(
|
||||||
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]:
|
"/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:
|
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
|
||||||
return a.id == b.id or a.line == b.line
|
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)
|
response = await shopping.request(conn, person, meal=meal)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class Ok(ApiModel):
|
class Ok(ApiModel):
|
||||||
ok: bool = True
|
ok: bool = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,7 @@ class BaseLinkedModel(ApiModel):
|
||||||
if id_key in data:
|
if id_key in data:
|
||||||
# If the id_key already exists, ensure it matches the value's id
|
# If the id_key already exists, ensure it matches the value's id
|
||||||
if data[id_key] != value.id:
|
if data[id_key] != value.id:
|
||||||
raise ValueError(
|
raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}")
|
||||||
f"ID mismatch for {key}: {data[id_key]} != {value.id}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# If the id_key does not exist, set it to the value's id
|
# If the id_key does not exist, set it to the value's id
|
||||||
data[id_key] = value.id
|
data[id_key] = value.id
|
||||||
|
|
|
||||||
10
dev-requirements.txt
Normal file
10
dev-requirements.txt
Normal file
|
|
@ -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
|
||||||
|
|
@ -23,7 +23,9 @@ async def create(conn):
|
||||||
);"""
|
);"""
|
||||||
)
|
)
|
||||||
# Useful indexes
|
# 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);")
|
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(
|
async with conn.execute(
|
||||||
f"""
|
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
|
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||||
WHERE Ingredient.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(
|
async with conn.execute(
|
||||||
f"""
|
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
|
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||||
WHERE recipe_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]."""
|
"""Fetch ingredients for many recipes in one query. Returns recipe_id -> [Ingredient]."""
|
||||||
if not recipe_ids:
|
if not recipe_ids:
|
||||||
return {}
|
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]
|
ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS]
|
||||||
product_cols = [f"product.{key}" for key in Product.KEYS]
|
product_cols = [f"product.{key}" for key in Product.KEYS]
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT {','.join(ingredient_cols + product_cols)}
|
SELECT {",".join(ingredient_cols + product_cols)}
|
||||||
FROM Ingredient AS ingredient
|
FROM Ingredient AS ingredient
|
||||||
LEFT JOIN Product AS product ON ingredient.product_id = product.id
|
LEFT JOIN Product AS product ON ingredient.product_id = product.id
|
||||||
WHERE ingredient.recipe_id IN ({placeholders})
|
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(
|
async with conn.execute(
|
||||||
f"""
|
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
|
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||||
WHERE meal_id = ?
|
WHERE meal_id = ?
|
||||||
""",
|
""",
|
||||||
|
|
|
||||||
12
main.py
12
main.py
|
|
@ -79,7 +79,9 @@ async def validation_exc_handler(request: Request, exc: Exception):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
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,
|
errors=errors,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
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:
|
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
|
# OpenAPI augmentation
|
||||||
extend_with_problem_and_cookie_auth(app)
|
extend_with_problem_and_cookie_auth(app)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ async def create(conn):
|
||||||
);"""
|
);"""
|
||||||
)
|
)
|
||||||
# Useful indexes
|
# 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(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -53,7 +55,9 @@ async def create(conn):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Index for faster lookup of recipes by meal
|
# 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):
|
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 def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
SELECT {",".join(Meal.KEYS)} FROM Meal
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
|
|
@ -144,7 +148,7 @@ async def find_upcoming_meals_by_date_range(
|
||||||
) -> AsyncIterator[Meal]:
|
) -> AsyncIterator[Meal]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
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
|
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL
|
||||||
""",
|
""",
|
||||||
(start, end),
|
(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 def load_recipes(conn, meal: Meal) -> None:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
|
SELECT {",".join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
|
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
|
||||||
WHERE MealRecipe.meal_id = ?
|
WHERE MealRecipe.meal_id = ?
|
||||||
|
|
|
||||||
14
openapi.json
14
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": {
|
"422": {
|
||||||
"description": "Validation Error",
|
"description": "Validation Error",
|
||||||
"content": {
|
"content": {
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,9 @@ async def count_by_name(conn, name: str) -> int:
|
||||||
return int(row[0]) if row else 0
|
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."""
|
"""Compute a prevCursor string for paginated persons, respecting optional name LIKE filter."""
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
@ -161,6 +163,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||||
else:
|
else:
|
||||||
query = """
|
query = """
|
||||||
|
|
@ -171,6 +174,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
params = (first_id, limit)
|
params = (first_id, limit)
|
||||||
|
|
||||||
async with conn.execute(query, params) as c:
|
async with conn.execute(query, params) as c:
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ async def create(conn):
|
||||||
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
|
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Product.KEYS)} FROM Product
|
SELECT {",".join(Product.KEYS)} FROM Product
|
||||||
WHERE id IN (
|
WHERE id IN (
|
||||||
SELECT food_item_id FROM ProductTag
|
SELECT food_item_id FROM ProductTag
|
||||||
WHERE tag = ?
|
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 def find_product_by_id(conn, product_id: int) -> Optional[Product]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Product.KEYS)} FROM Product
|
SELECT {",".join(Product.KEYS)} FROM Product
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
LIMIT 1
|
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 def find_product_by_key(conn, shop_code: str, product_id: str) -> Optional[Product]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Product.KEYS)} FROM Product
|
SELECT {",".join(Product.KEYS)} FROM Product
|
||||||
WHERE shop_code = ? AND product_id = ?
|
WHERE shop_code = ? AND product_id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
|
|
@ -84,8 +84,8 @@ async def insert_product(conn, product: Product, data: dict):
|
||||||
|
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO Product ({','.join(insert_keys)}, raw_data)
|
INSERT INTO Product ({",".join(insert_keys)}, raw_data)
|
||||||
VALUES ({','.join(['?'] * len(insert_keys))}, ?)
|
VALUES ({",".join(["?"] * len(insert_keys))}, ?)
|
||||||
""",
|
""",
|
||||||
(*insert_values, json.dumps(data)),
|
(*insert_values, json.dumps(data)),
|
||||||
) as cursor:
|
) as cursor:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
[tool.black]
|
|
||||||
line-length = 100
|
|
||||||
target-version = ["py311"]
|
|
||||||
include = "\\.pyi?$"
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ async def create(conn):
|
||||||
);"""
|
);"""
|
||||||
)
|
)
|
||||||
# Useful indexes for filtering/pagination
|
# 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(
|
||||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_recipe_name_hidden_id ON Recipe(name, date_hidden, id);")
|
"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):
|
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]
|
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
|
||||||
|
|
||||||
insert_stmt = f"""
|
insert_stmt = f"""
|
||||||
INSERT INTO Recipe ({','.join(fields_to_insert)})
|
INSERT INTO Recipe ({",".join(fields_to_insert)})
|
||||||
VALUES ({','.join(['?'] * len(fields_to_insert))})
|
VALUES ({",".join(["?"] * len(fields_to_insert))})
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async with conn.execute(insert_stmt, actual_values) as cursor:
|
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 def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
LIMIT 1
|
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 def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
||||||
WHERE name LIKE ? AND date_hidden IS NULL
|
WHERE name LIKE ? AND date_hidden IS NULL
|
||||||
""",
|
""",
|
||||||
(f"%{name}%",),
|
(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 def get_all(conn) -> AsyncIterator[Recipe]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
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:
|
) as cursor:
|
||||||
async for row in 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
|
after = after_id if after_id is not None else -1
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Recipe.KEYS)}
|
SELECT {",".join(Recipe.KEYS)}
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
WHERE date_hidden IS NULL AND id > ?
|
WHERE date_hidden IS NULL AND id > ?
|
||||||
ORDER BY 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
|
after = after_id if after_id is not None else -1
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(Recipe.KEYS)}
|
SELECT {",".join(Recipe.KEYS)}
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
WHERE name LIKE ? AND date_hidden IS NULL AND id > ?
|
WHERE name LIKE ? AND date_hidden IS NULL AND id > ?
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
|
|
@ -172,6 +176,7 @@ async def compute_prev_cursor(
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||||
else:
|
else:
|
||||||
query = """
|
query = """
|
||||||
|
|
@ -182,6 +187,7 @@ async def compute_prev_cursor(
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
params = (first_id, limit)
|
params = (first_id, limit)
|
||||||
|
|
||||||
async with conn.execute(query, params) as c:
|
async with conn.execute(query, params) as c:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Centralized runtime settings for the Doof backend.
|
||||||
No external dependencies; reads from environment only so it can be imported
|
No external dependencies; reads from environment only so it can be imported
|
||||||
anywhere (including tests) without side effects.
|
anywhere (including tests) without side effects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,12 @@ async def create(conn):
|
||||||
);"""
|
);"""
|
||||||
)
|
)
|
||||||
# Useful indexes for queries
|
# Useful indexes for queries
|
||||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_list_id ON ShoppingListItem(list_id);")
|
await conn.execute(
|
||||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_meal_id ON ShoppingListItem(meal_id);")
|
"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(
|
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;"
|
"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]
|
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
||||||
|
|
||||||
select = f"""
|
select = f"""
|
||||||
SELECT {','.join(request_cols)}
|
SELECT {",".join(request_cols)}
|
||||||
FROM ShoppingListItem
|
FROM ShoppingListItem
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -270,7 +274,7 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
||||||
shopping_list: Optional[ShoppingList] = None
|
shopping_list: Optional[ShoppingList] = None
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList
|
SELECT {",".join(ShoppingList.KEYS)} FROM ShoppingList
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
|
|
@ -293,9 +297,9 @@ async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[
|
||||||
|
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(ShoppingListItem.KEYS)}
|
SELECT {",".join(ShoppingListItem.KEYS)}
|
||||||
FROM ShoppingListItem
|
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,
|
meal_ids,
|
||||||
) as cursor:
|
) as cursor:
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,11 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||||
|
|
||||||
# Always act as an authenticated user for tests that require auth
|
# Always act as an authenticated user for tests that require auth
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
||||||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||||
self.client = TestClient(main.app)
|
self.client = TestClient(main.app)
|
||||||
return await super().asyncSetUp()
|
return await super().asyncSetUp()
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
||||||
}
|
}
|
||||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertIn(
|
self.assertIn("Meal must have at least one recipe or ingredient", response.json()["title"])
|
||||||
"Meal must have at least one recipe or ingredient", response.json()["title"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_create_meal_invalid_duplicate_chefs(self):
|
def test_create_meal_invalid_duplicate_chefs(self):
|
||||||
"""Test creating a meal with duplicate chefs (should fail validation)"""
|
"""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):
|
def test_delete_meal_not_found(self):
|
||||||
"""Test deleting a meal that doesn't exist"""
|
"""Test deleting a meal that doesn't exist"""
|
||||||
|
|
||||||
# Override the cookie_person dependency to return a test user
|
# Override the cookie_person dependency to return a test user
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -569,6 +568,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_create_recipe_valid(self):
|
def test_create_recipe_valid(self):
|
||||||
"""Test creating a valid recipe - currently fails due to auth dependency issues"""
|
"""Test creating a valid recipe - currently fails due to auth dependency issues"""
|
||||||
|
|
||||||
# The authentication dependency injection isn't working properly in tests
|
# The authentication dependency injection isn't working properly in tests
|
||||||
# This would require a more complex setup to properly mock FastAPI dependencies
|
# This would require a more complex setup to properly mock FastAPI dependencies
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
|
|
@ -605,6 +605,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_create_recipe_no_ingredients(self):
|
def test_create_recipe_no_ingredients(self):
|
||||||
"""Test creating a recipe without ingredients - auth dependency issues prevent proper testing"""
|
"""Test creating a recipe without ingredients - auth dependency issues prevent proper testing"""
|
||||||
|
|
||||||
# The authentication dependency injection isn't working properly in tests
|
# The authentication dependency injection isn't working properly in tests
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -631,6 +632,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_mark_consumed_invalid_timezone(self):
|
def test_mark_consumed_invalid_timezone(self):
|
||||||
"""Test marking meal as consumed with invalid timezone"""
|
"""Test marking meal as consumed with invalid timezone"""
|
||||||
|
|
||||||
# Override the cookie_person dependency to return a test user
|
# Override the cookie_person dependency to return a test user
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -664,6 +666,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_request_meal_not_found(self):
|
def test_request_meal_not_found(self):
|
||||||
"""Test requesting a meal that doesn't exist"""
|
"""Test requesting a meal that doesn't exist"""
|
||||||
|
|
||||||
# Override the cookie_person dependency to return a test user
|
# Override the cookie_person dependency to return a test user
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -682,6 +685,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_unrequest_meal_not_found(self):
|
def test_unrequest_meal_not_found(self):
|
||||||
"""Test unrequesting a meal that doesn't exist"""
|
"""Test unrequesting a meal that doesn't exist"""
|
||||||
|
|
||||||
# Override the cookie_person dependency to return a test user
|
# Override the cookie_person dependency to return a test user
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -699,6 +703,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_get_my_shopping_list_empty(self):
|
def test_get_my_shopping_list_empty(self):
|
||||||
"""Test getting empty shopping list when no items are requested"""
|
"""Test getting empty shopping list when no items are requested"""
|
||||||
|
|
||||||
# Override the cookie_person dependency to return a test user
|
# Override the cookie_person dependency to return a test user
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -755,6 +760,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_sync_my_shopping_list_empty_to_empty(self):
|
def test_sync_my_shopping_list_empty_to_empty(self):
|
||||||
"""Test syncing empty list with empty current state"""
|
"""Test syncing empty list with empty current state"""
|
||||||
|
|
||||||
# Override the cookie_person dependency to return a test user
|
# Override the cookie_person dependency to return a test user
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -1102,6 +1108,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
def test_sync_my_shopping_list_invalid_json(self):
|
def test_sync_my_shopping_list_invalid_json(self):
|
||||||
"""Test sync_my_shopping_list with invalid JSON data"""
|
"""Test sync_my_shopping_list with invalid JSON data"""
|
||||||
|
|
||||||
# Override the cookie_person dependency
|
# Override the cookie_person dependency
|
||||||
async def override_cookie_person():
|
async def override_cookie_person():
|
||||||
return test_data.Persons.jacob
|
return test_data.Persons.jacob
|
||||||
|
|
@ -1115,7 +1122,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
||||||
json=[
|
json=[
|
||||||
{
|
{
|
||||||
"id": "not_a_number", # Invalid ID type
|
"id": "not_a_number", # Invalid ID type
|
||||||
"name": "Test Ingredient"
|
"name": "Test Ingredient",
|
||||||
# Missing required fields
|
# Missing required fields
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
61
tests/test_shopping_api.py
Normal file
61
tests/test_shopping_api.py
Normal file
|
|
@ -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")
|
||||||
Loading…
Reference in a new issue