diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 582ef8d..73d4556 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,73 +1,72 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose -{ - "name": "FastAPI Todo App", - - // Update the 'dockerComposeFile' list if you have more compose files or use different names. - // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. - "dockerComposeFile": [ - "../docker-compose.yml", - "docker-compose.yml" - ], - - // The 'service' property is the name of the service for the container that VS Code should - // use. Update this value and .devcontainer/docker-compose.yml to the real service name. - "service": "app", - - // The optional 'workspaceFolder' property is the path VS Code should open by default when - // connected. This is typically a file mount in .devcontainer/docker-compose.yml - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", - - // Features to add to the dev container. More info: https://containers.dev/features. - "features": { - "ghcr.io/devcontainers/features/python:1": { - "version": "3.13", - "installTools": true - }, - "ghcr.io/devcontainers/features/git:1": { - "version": "latest" - } - }, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [8000], - - // Uncomment the next line if you want start specific services in your Docker Compose config. - // "runServices": [], - - // Uncomment the next line if you want to keep your containers running after VS Code shuts down. - // "shutdownAction": "none", - - // Install dependencies after container is created - "postCreateCommand": "pip install poetry && poetry install", - - // Configure tool-specific properties. - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.vscode-pylance", - "charliermarsh.ruff", - "ms-python.mypy-type-checker" - ], - "settings": { - "python.defaultInterpreterPath": "/usr/local/bin/python", - "python.linting.enabled": true, - "python.formatting.provider": "none", - "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" - } - }, - "ruff.enable": true, - "mypy-type-checker.importStrategy": "fromEnvironment" - } - } - } - - // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "devcontainer" -} +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +{ + "name": "FastAPI Todo App", + + // Update the 'dockerComposeFile' list if you have more compose files or use different names. + // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. + "dockerComposeFile": [ + "../docker-compose.yml", + "docker-compose.yml" + ], + + // The 'service' property is the name of the service for the container that VS Code should + // use. Update this value and .devcontainer/docker-compose.yml to the real service name. + "service": "app", + + // The optional 'workspaceFolder' property is the path VS Code should open by default when + // connected. This is typically a file mount in .devcontainer/docker-compose.yml + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.13", + "installTools": true + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "latest" + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [8000], + // Uncomment the next line if you want start specific services in your Docker Compose config. + // "runServices": [], + + // Uncomment the next line if you want to keep your containers running after VS Code shuts down. + // "shutdownAction": "none", + + // Install dependencies after container is created + "postCreateCommand": "pip install poetry && poetry install", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "ms-python.mypy-type-checker" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.formatting.provider": "none", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } + }, + "ruff.enable": true, + "mypy-type-checker.importStrategy": "fromEnvironment" + } + } + } + + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 47317e4..fbf8233 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,26 +1,35 @@ -version: '3.8' -services: - # Update this to the name of the service you want to work with in your docker-compose.yml file - app: - # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer - # folder. Note that the path of the Dockerfile and context is relative to the *primary* - # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile" - # array). The sample below assumes your primary file is in the root of your project. - # - # build: - # context: . - # dockerfile: .devcontainer/Dockerfile - - volumes: - # Update this to wherever you want VS Code to mount the folder of your project - - ..:/workspaces:cached - - # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. - # cap_add: - # - SYS_PTRACE - # security_opt: - # - seccomp:unconfined - - # Overrides default command so things don't shut down after the process ends. - command: sleep infinity - +version: '3.8' +services: + # Update this to the name of the service you want to work with in your docker-compose.yml file + app: + # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer + # folder. Note that the path of the Dockerfile and context is relative to the *primary* + # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile" + # array). The sample below assumes your primary file is in the root of your project. + # + # build: + # context: . + # dockerfile: .devcontainer/Dockerfile + environment: + - REDIS_URL=redis://redis:6379 + depends_on: + - redis + + volumes: + # Update this to wherever you want VS Code to mount the folder of your project + - ..:/workspaces:cached + + # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. + # cap_add: + # - SYS_PTRACE + # security_opt: + # - seccomp:unconfined + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d421390..bea5eac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,46 +1,46 @@ -name: CI - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Poetry - run: | - curl -sSL https://install.python-poetry.org | python3 - - echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Install dependencies - run: poetry install - - - name: Run linting with Ruff - run: poetry run ruff check . - - - name: Run formatting check with Ruff - run: poetry run ruff format --check . - - - name: Run type checking with mypy - run: poetry run mypy app/ - - - name: Run tests with pytest - run: poetry run pytest -v --cov=app --cov-report=xml --cov-report=term - - - name: Upload coverage to Codecov - if: matrix.python-version == '3.13' - uses: codecov/codecov-action@v4 - with: - file: ./coverage.xml +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: poetry install + + - name: Run linting with Ruff + run: poetry run ruff check . + + - name: Run formatting check with Ruff + run: poetry run ruff format --check . + + - name: Run type checking with mypy + run: poetry run mypy app/ + + - name: Run tests with pytest + run: poetry run pytest -v --cov=app --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.13' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml fail_ci_if_error: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index d7f11a2..ba63f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,81 +1,81 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments -venv/ -env/ -ENV/ -env.bak/ -venv.bak/ -.venv/ - -# Poetry -poetry.lock - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -.tox/ -.hypothesis/ -*.cover -/coverage -/.nyc_output - -# Type checking -.mypy_cache/ -.dmypy.json -dmypy.json -.pyre/ -.pytype/ - -# Logs -logs -*.log - -# OS -.DS_Store - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace -*.swp -*.swo -*~ - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# Environment variables -.env -.env.local +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ +.venv/ + +# Poetry +poetry.lock + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.hypothesis/ +*.cover +/coverage +/.nyc_output + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ + +# Logs +logs +*.log + +# OS +.DS_Store + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace +*.swp +*.swo +*~ + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Environment variables +.env +.env.local .env.*.local \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml index 6ebc4c0..1a14d0f 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,29 +1,29 @@ -# Ruff configuration -# Ruff is an extremely fast Python linter and formatter, written in Rust - -line-length = 100 -target-version = "py39" - -[lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "ARG", # flake8-unused-arguments - "SIM", # flake8-simplify -] -ignore = [] - -# Allow autofix for all enabled rules -fixable = ["ALL"] -unfixable = [] - -[format] -quote-style = "double" -indent-style = "space" -skip-magic-trailing-comma = false -line-ending = "auto" +# Ruff configuration +# Ruff is an extremely fast Python linter and formatter, written in Rust + +line-length = 100 +target-version = "py39" + +[lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [] + +# Allow autofix for all enabled rules +fixable = ["ALL"] +unfixable = [] + +[format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/.vscode/settings.json b/.vscode/settings.json index e789492..d4a78fc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:poetry", - "python-envs.defaultPackageManager": "ms-python.python:poetry", - "python-envs.pythonProjects": [] +{ + "python-envs.defaultEnvManager": "ms-python.python:poetry", + "python-envs.defaultPackageManager": "ms-python.python:poetry", + "python-envs.pythonProjects": [] } \ No newline at end of file diff --git a/README.md b/README.md index 0776357..2c06d02 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,971 @@ -# python-interview / TodoAPI - -[![Open in Coder](https://dev.crunchloop.io/open-in-coder.svg)](https://dev.crunchloop.io/templates/fly-containers/workspace?param.Git%20Repository=git@github.com:crunchloop/python-interview.git) - -This is a simple Todo List API built with FastAPI and Python 3.13+. This project is currently being used for Python full-stack candidates. - -## Features - -- **FastAPI** - Modern, fast web framework for building APIs -- **Python 3.13+** - Latest Python with full type hints -- **Pydantic v2** - Data validation using Python type annotations -- **In-memory storage** - Simple data persistence (resets on restart) -- **Poetry** - Modern dependency management -- **pytest** - Comprehensive unit tests with mocking -- **Ruff** - Extremely fast Python linter and formatter -- **mypy** - Static type checker with strict mode -- **DevContainer** - VS Code development container support - -## Prerequisites - -- Python 3.13+ or Docker with VS Code DevContainer support -- Poetry (if running locally without Docker) - -## Installation - -### Using Poetry (Local) - -```bash -# Install Poetry if you haven't already -curl -sSL https://install.python-poetry.org | python3 - - -# Install dependencies -poetry install - -# Activate virtual environment -poetry shell -``` - -### Using DevContainer (Recommended) - -1. Open the project in VS Code -2. Install the "Dev Containers" extension -3. Press `F1` and select "Dev Containers: Reopen in Container" -4. Dependencies will be installed automatically - -## Running the app - -### Development mode with hot reload - -```bash -# Using Poetry -poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - -# Or if inside poetry shell -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` - -### Production mode - -```bash -poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 -``` - -The API will be available at: -- **API**: http://localhost:8000 -- **Interactive API docs (Swagger)**: http://localhost:8000/docs -- **Alternative API docs (ReDoc)**: http://localhost:8000/redoc - -## API Endpoints - -All endpoints are prefixed with `/api/todolists`: - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/todolists` | Get all todo lists | -| GET | `/api/todolists/{id}` | Get a specific todo list | -| POST | `/api/todolists` | Create a new todo list | -| PUT | `/api/todolists/{id}` | Update an existing todo list | -| DELETE | `/api/todolists/{id}` | Delete a todo list | - -## Testing - -```bash -# Run all tests -poetry run pytest - -# Run tests with coverage -poetry run pytest --cov=app --cov-report=html - -# Run tests in watch mode -poetry run pytest-watch - -# Run tests with verbose output -poetry run pytest -v -``` - -## Code Quality - -### Linting and Formatting with Ruff - -```bash -# Check for linting errors -poetry run ruff check . - -# Fix linting errors automatically -poetry run ruff check --fix . - -# Format code -poetry run ruff format . -``` - -### Type Checking with mypy - -```bash -# Run type checker -poetry run mypy app/ - -# Run type checker on all files -poetry run mypy . -``` - -## Project Structure - -``` -. -├── app/ -│ ├── __init__.py -│ ├── main.py # FastAPI application entry point -│ ├── models.py # Pydantic models (schemas) -│ ├── routers/ -│ │ ├── __init__.py -│ │ └── todo_lists.py # TodoList API endpoints -│ └── services/ -│ ├── __init__.py -│ └── todo_lists.py # Business logic and in-memory storage -├── tests/ -│ ├── __init__.py -│ └── test_todo_lists.py # Unit tests for all endpoints -├── .devcontainer/ # VS Code DevContainer configuration -├── pyproject.toml # Poetry dependencies and tool configs -├── .ruff.toml # Ruff linter/formatter configuration -├── mypy.ini # mypy type checker configuration -└── README.md -``` - -## Development Tools - -This project uses modern Python development tools: - -- **Poetry**: Dependency management and packaging -- **Ruff**: Extremely fast linter and formatter (replaces Black, isort, flake8) -- **mypy**: Static type checker with strict mode enabled -- **pytest**: Testing framework with async support -- **httpx**: HTTP client for testing FastAPI endpoints - -## In-Memory Storage - -This application uses in-memory storage (Python lists/dicts). Data will be lost when the application restarts. This is intentional for simplicity and is suitable for interview/demo purposes. - -Check integration tests at: https://github.com/crunchloop/interview-tests - -## Contact - -- Martín Fernández (mfernandez@crunchloop.io) - -## About Crunchloop - -![crunchloop](https://s3.amazonaws.com/crunchloop.io/logo-blue.png) - -We strongly believe in giving back :rocket:. Let's work together [`Get in touch`](https://crunchloop.io/#contact). +# python-interview / TodoAPI + +[![Open in Coder](https://dev.crunchloop.io/open-in-coder.svg)](https://dev.crunchloop.io/templates/fly-containers/workspace?param.Git%20Repository=git@github.com:crunchloop/python-interview.git) + +This is a simple Todo List API built with FastAPI and Python 3.13+. This project is currently being used for Python full-stack candidates. + +## Features + +- **FastAPI** - Modern, fast web framework for building APIs +- **Python 3.13+** - Latest Python with full type hints +- **Pydantic v2** - Data validation using Python type annotations +- **In-memory storage** - Simple data persistence (resets on restart) +- **Poetry** - Modern dependency management +- **pytest** - Comprehensive unit tests with mocking +- **Ruff** - Extremely fast Python linter and formatter +- **mypy** - Static type checker with strict mode +- **DevContainer** - VS Code development container support + +## Prerequisites + +- Python 3.13+ or Docker with VS Code DevContainer support +- Poetry (if running locally without Docker) + +## Installation + +### Using Poetry (Local) + +```bash +# Install Poetry if you haven't already +curl -sSL https://install.python-poetry.org | python3 - + +# Install dependencies +poetry install + +# Activate virtual environment +poetry shell +``` + +### Using DevContainer (Recommended) + +1. Open the project in VS Code +2. Install the "Dev Containers" extension +3. Press `F1` and select "Dev Containers: Reopen in Container" +4. Dependencies will be installed automatically + +## Running the app + +### Development mode with hot reload + +```bash +# Using Poetry +poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Or if inside poetry shell +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Production mode + +```bash +poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +The API will be available at: +- **API**: http://localhost:8000 +- **Interactive API docs (Swagger)**: http://localhost:8000/docs +- **Alternative API docs (ReDoc)**: http://localhost:8000/redoc + +## API Endpoints + +All endpoints are prefixed with `/api/todolists`: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/todolists` | Get all todo lists | +| GET | `/api/todolists/{id}` | Get a specific todo list | +| POST | `/api/todolists` | Create a new todo list | +| PUT | `/api/todolists/{id}` | Update an existing todo list | +| DELETE | `/api/todolists/{id}` | Delete a todo list | + +## Testing + +```bash +# Run all tests +poetry run pytest + +# Run tests with coverage +poetry run pytest --cov=app --cov-report=html + +# Run tests in watch mode +poetry run pytest-watch + +# Run tests with verbose output +poetry run pytest -v +``` + +## Code Quality + +### Linting and Formatting with Ruff + +```bash +# Check for linting errors +poetry run ruff check . + +# Fix linting errors automatically +poetry run ruff check --fix . + +# Format code +poetry run ruff format . +``` + +### Type Checking with mypy + +```bash +# Run type checker +poetry run mypy app/ + +# Run type checker on all files +poetry run mypy . +``` + +## Project Structure + +``` +. +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI application entry point +│ ├── models.py # Pydantic models (schemas) +│ ├── routers/ +│ │ ├── __init__.py +│ │ └── todo_lists.py # TodoList API endpoints +│ └── services/ +│ ├── __init__.py +│ └── todo_lists.py # Business logic and in-memory storage +├── tests/ +│ ├── __init__.py +│ └── test_todo_lists.py # Unit tests for all endpoints +├── .devcontainer/ # VS Code DevContainer configuration +├── pyproject.toml # Poetry dependencies and tool configs +├── .ruff.toml # Ruff linter/formatter configuration +├── mypy.ini # mypy type checker configuration +└── README.md +``` + +## Development Tools + +This project uses modern Python development tools: + +- **Poetry**: Dependency management and packaging +- **Ruff**: Extremely fast linter and formatter (replaces Black, isort, flake8) +- **mypy**: Static type checker with strict mode enabled +- **pytest**: Testing framework with async support +- **httpx**: HTTP client for testing FastAPI endpoints + +## In-Memory Storage + +This application uses in-memory storage (Python lists/dicts). Data will be lost when the application restarts. This is intentional for simplicity and is suitable for interview/demo purposes. + +Check integration tests at: https://github.com/crunchloop/interview-tests + +## Contact + +- Martín Fernández (mfernandez@crunchloop.io) + +## About Crunchloop + +![crunchloop](https://s3.amazonaws.com/crunchloop.io/logo-blue.png) + +We strongly believe in giving back :rocket:. Let's work together [`Get in touch`](https://crunchloop.io/#contact). + +--- + +# Backend Setup and Documentation + +## Overview + +This is a FastAPI-based backend for a TodoList application that uses Redis as both a database and task queue system. The application provides a REST API for managing todo lists and items, with asynchronous task processing capabilities for bulk operations. + +## Tech Stack + +- **FastAPI** - Modern, fast web framework for building APIs +- **Python 3.13** - Programming language +- **Redis** - In-memory data structure store (used as database and message broker) +- **RQ (Redis Queue)** - Simple Python library for queueing jobs +- **Poetry** - Dependency management and packaging +- **Pydantic** - Data validation using Python type annotations +- **Pytest** - Testing framework + +## Features + +- Full CRUD operations for Todo Lists +- Full CRUD operations for Todo Items +- Asynchronous task processing with Redis Queue +- Background workers for long-running operations +- Job status tracking +- Duplicate name/title validation +- Bulk operations (complete all items) +- CORS enabled for frontend integration +- Comprehensive test suite + +## Prerequisites + +Before running this project, ensure you have: + +- **Docker** and **Docker Compose** +- **Python 3.13** (if running locally without Docker) +- **Poetry** (for dependency management) +- **Redis** (included in Docker setup) + +## Redis Architecture + +This project uses Redis in two distinct ways: + +### 1. Redis as Database (DB 1) +- Stores all TodoLists and TodoItems as JSON +- Maintains auto-incrementing ID counters +- Provides fast key-value storage + +### 2. Redis as Task Queue (DB 0) +- Manages asynchronous job queue using RQ +- Handles background task processing +- Enables non-blocking operations for expensive tasks + +**Why Redis?** +- Fast in-memory operations +- Simple key-value storage for small-scale applications +- Built-in pub/sub for job queuing +- Easy to scale and deploy + +## Installation + +### Option 1: Docker with Dev Containers (Recommended) + +This project includes a complete dev container setup for consistent development environments. + +**Dev Container Configuration** (`.devcontainer/devcontainer.json`): +```json +{ + "name": "FastAPI Todo App", + "dockerComposeFile": [ + "../docker-compose.yml", + "docker-compose.yml" + ], + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.13", + "installTools": true + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "latest" + } + }, + "forwardPorts": [8000], + "postCreateCommand": "pip install poetry && poetry install", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "ms-python.mypy-type-checker" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.formatting.provider": "none", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } + }, + "ruff.enable": true, + "mypy-type-checker.importStrategy": "fromEnvironment" + } + } + } +} +``` + +**Steps to run with Docker:** + +1. Open the project in VS Code +2. Install the "Dev Containers" extension +3. Press `F1` and select "Dev Containers: Reopen in Container" +4. Wait for the container to build and dependencies to install +5. The `postCreateCommand` will automatically run `poetry install` + +### Option 2: Local Development + +1. Install Poetry: +```bash +pip install poetry +``` + +2. Install dependencies: +```bash +poetry install +``` + +3. Ensure Redis is running locally on port 6379 + +## Docker Configuration + +### Root docker-compose.yml +```yaml +version: '3.8' +services: + app: + image: mcr.microsoft.com/devcontainers/python:1-3.13-bookworm + volumes: + - .:/workspaces/python-interview + ports: + - "8000:8000" + command: sleep infinity + working_dir: /workspaces/python-interview + networks: + - fastapi-network + +networks: + fastapi-network: + driver: bridge +``` + +### Dev Container docker-compose.yml +```yaml +version: '3.8' +services: + app: + environment: + - REDIS_URL=redis://redis:6379 + depends_on: + - redis + volumes: + - ..:/workspaces:cached + command: sleep infinity + + redis: + image: redis:7-alpine + ports: + - "6379:6379" +``` + +**Important:** The dev container setup includes Redis automatically. The app service depends on Redis and connects via `host.docker.internal:6379`. + +## Project Structure + +```bash +app/ +├── routers/ # API route handlers +│ ├── __init__.py +│ ├── jobs.py # Job status endpoints +│ ├── todo_items.py # Todo items CRUD endpoints +│ └── todo_lists.py # Todo lists CRUD endpoints +├── services/ # Business logic layer +│ ├── __init__.py +│ ├── todo_items.py # Todo items service +│ └── todo_lists.py # Todo lists service (Redis storage) +├── __init__.py +├── main.py # FastAPI application entry point +├── models.py # Pydantic models for validation +├── redis_config.py # Redis connection and queue setup +└── worker.py # Background worker for async tasks +scripts/ +└── start_worker.py # Helper script to start worker +tests/ +├── __init__.py +├── test_todo_items.py # Tests for todo items +└── test_todo_lists.py # Tests for todo lists + +Configuration Files: +├── docker-compose.yml # Docker services configuration +├── poetry.toml # Poetry configuration +├── poetry.lock # Locked dependencies +├── pyproject.toml # Project metadata and dependencies +├── .ruff.toml # Ruff linter configuration +├── mypy.ini # MyPy type checker configuration +├── .gitignore # Git ignore rules +└── README.md # Project documentation +``` + +## Running the Application + +### 1. Start the FastAPI Server +```bash +poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +**Expected output:** + +vscode ➜ /workspaces/python-interview (main) $ poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +INFO: Will watch for changes in these directories: ['/workspaces/python-interview'] +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: Started reloader process [2940] using WatchFiles +INFO: Started server process [3069] +INFO: Waiting for application startup. +INFO: Application startup complete. + +The API will be available at `http://localhost:8000` + +### 2. Start the Background Worker + +**Important:** The worker must be running for asynchronous operations (like "complete all") to work. +```bash +poetry run python -m app.worker +``` + +**Expected output:** + +vscode ➜ /workspaces/python-interview (main) $ poetry run python -m app.worker +Worker started. Connecting to Redis... +Worker ready. Waiting for jobs... +15:51:27 Worker e6e648a08bf84a8e9b851cba34ef64aa: started with PID 3371, version 2.6.1 +15:51:27 Worker e6e648a08bf84a8e9b851cba34ef64aa: subscribing to channel rq:pubsub:e6e648a08bf84a8e9b851cba34ef64aa +15:51:27 *** Listening on default... +15:51:27 Worker e6e648a08bf84a8e9b851cba34ef64aa: cleaning registries for queue: default + +### 3. Access the API Documentation + +FastAPI provides automatic interactive API documentation: + +- **Swagger UI:** http://localhost:8000/docs +- **ReDoc:** http://localhost:8000/redoc + +## API Endpoints + +### Health Check + +GET / + +Returns a simple message indicating the API is running. + +### Todo Lists Endpoints + +```http +GET /api/todolists # Get all todo lists +GET /api/todolists/{id} # Get a specific todo list +POST /api/todolists # Create a new todo list +PUT /api/todolists/{id} # Update a todo list +DELETE /api/todolists/{id} # Delete a todo list +``` + +### Todo Items Endpoints + +```http +GET /api/todolists/{list_id}/items # Get all items in a list +GET /api/todolists/{list_id}/items/{item_id} # Get a specific item +POST /api/todolists/{list_id}/items # Create a new item +PUT /api/todolists/{list_id}/items/{item_id} # Update an item +PATCH /api/todolists/{list_id}/items/{item_id}/toggle # Toggle item completion +DELETE /api/todolists/{list_id}/items/{item_id} # Delete an item +POST /api/todolists/{list_id}/items/complete-all # Complete all items (async) +``` + +### Job Status Endpoints + +GET /api/jobs/{job_id} # Check status of an async job + +## Asynchronous Task Processing + +### How "Complete All" Works + +The "complete all" operation is implemented as an asynchronous task to handle large lists efficiently: + +#### 1. Request Flow + +Client → POST /api/todolists/{id}/items/complete-all +↓ +Enqueue job to Redis Queue +↓ +Return job_id immediately (202 Accepted) +↓ +Background Worker processes job +↓ +Update all items in Redis DB + +#### 2. Components + +**Router (`routers/todo_items.py`):** +```python +@router.post("/complete-all", status_code=202) +async def complete_all_async(todo_list_id: int) -> dict: + """Queues the task and responds immediately.""" + job_id = enqueue_complete_all(todo_list_id) + return { + "message": "queued job", + "job_id": job_id, + "todo_list_id": todo_list_id, + "check_status": f"/api/jobs/{job_id}", + } +``` + +**Redis Configuration (`redis_config.py`):** +```python +# Redis connection for queue (DB 0) +redis_conn = redis.Redis(host="host.docker.internal", port=6379, db=0) +queue = Queue(connection=redis_conn) + +def enqueue_complete_all(todo_list_id: int) -> str: + """Enqueues the job and returns job ID.""" + from app.worker import complete_all_task + job = queue.enqueue(complete_all_task, todo_list_id) + return job.id +``` + +**Worker (`worker.py`):** +```python +def complete_all_task(todo_list_id: int) -> dict: + """Task that the worker executes.""" + service = get_todo_item_service() + todo_list_service = get_todo_list_service() + + todo_list = todo_list_service.get(todo_list_id) + if not todo_list: + return {"error": "List not found"} + + completed_count = 0 + for i, item in enumerate(todo_list.items): + if not item.completed: + todo_list.items[i] = item.__class__( + id=item.id, + title=item.title, + description=item.description, + completed=True, + ) + completed_count += 1 + + if completed_count > 0: + todo_list_service.save(todo_list) + + return { + "completed": completed_count, + "message": f"Completed {completed_count} tasks" + } +``` + +#### 3. Checking Job Status + +After receiving a job_id, clients can check the status: +```bash +GET /api/jobs/{job_id} +``` + +Response: +```json +{ + "id": "abc123", + "status": "finished", + "result": { + "completed": 5, + "message": "Completed 5 tasks" + }, + "error": null +} +``` + +**Job Statuses:** +- `queued` - Job is waiting to be processed +- `started` - Job is currently being processed +- `finished` - Job completed successfully +- `failed` - Job failed with an error + +### Why Use Async Processing? + +1. **Non-blocking:** API responds immediately, doesn't wait for task completion +2. **Scalability:** Can handle large lists without timeout issues +3. **Better UX:** Frontend can show progress or poll for status +4. **Resource efficiency:** Worker can process jobs in background +5. **Error handling:** Failed jobs can be retried or logged + +## Data Models + +### TodoList +```python +class TodoList(BaseModel): + id: int + name: str + items: list[TodoItem] = [] +``` + +### TodoItem +```python +class TodoItem(BaseModel): + id: int + title: str + description: Optional[str] = None + completed: bool = False +``` + +### Create/Update DTOs +```python +class TodoListCreate(BaseModel): + name: str # min_length=1 + +class TodoListUpdate(BaseModel): + name: str # min_length=1 + +class TodoItemCreate(BaseModel): + title: str # min_length=1 + description: Optional[str] = None + completed: bool = False + +class TodoItemUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + completed: Optional[bool] = None +``` + +## Business Logic & Validation + +### TodoList Service + +**Features:** +- Duplicate name validation (case-insensitive) +- Auto-incrementing IDs using Redis counters +- JSON serialization/deserialization for Redis storage +- Cascade operations (items included with lists) + +**Redis Keys:** +- `todolist:{id}` - Stores serialized TodoList +- `todolist:next_id` - Global counter for list IDs + +### TodoItem Service + +**Features:** +- Duplicate title validation within each list (case-insensitive) +- Per-list ID generation +- Toggle completion status +- Bulk complete operation + +**Redis Keys:** +- `todoitem:{list_id}:next_id` - Counter for items in specific list +- Items are stored within their parent TodoList + +## Testing + +The project includes a comprehensive test suite using pytest. + +### Running Tests + +**Run all tests:** +```bash +poetry run pytest +``` + +**Run specific test file:** +```bash +poetry run pytest tests/test_todo_lists.py -v +poetry run pytest tests/test_todo_items.py -v +``` + +**Run tests with coverage:** +```bash +poetry run pytest --cov=app --cov-report=html +``` + +**Run tests in watch mode:** +```bash +poetry run pytest-watch +``` + +**Run tests with verbose output:** +```bash +poetry run pytest -v +``` + +### Test Coverage + +The test suite includes comprehensive unit tests using mocked services: + +**TodoList Tests (`test_todo_lists.py`):** +- **GET /api/todolists** - Returns all todo lists +- **GET /api/todolists** - Returns empty list when no todos exist +- **GET /api/todolists/{id}** - Returns a specific todo list by ID +- **GET /api/todolists/{id}** - Returns 404 when todo list not found +- **POST /api/todolists** - Creates new todo list successfully +- **POST /api/todolists** - Validates required fields (422 error) +- **POST /api/todolists** - Validates name is not empty (422 error) +- **PUT /api/todolists/{id}** - Updates existing todo list +- **PUT /api/todolists/{id}** - Returns 404 when todo list not found +- **PUT /api/todolists/{id}** - Validates required fields (422 error) +- **DELETE /api/todolists/{id}** - Deletes existing todo list (204 response) +- **DELETE /api/todolists/{id}** - Returns 404 when todo list not found + +**TodoItem Tests (`test_todo_items.py`):** +- **GET /api/todolists/{id}/items** - Returns all items from a list +- **GET /api/todolists/{id}/items/{item_id}** - Returns a specific item +- **GET /api/todolists/{id}/items/{item_id}** - Returns 404 when item not found +- **POST /api/todolists/{id}/items** - Creates new item successfully +- **POST /api/todolists/{id}/items** - Rejects duplicate titles (400 error) +- **PUT /api/todolists/{id}/items/{item_id}** - Updates existing item +- **PUT /api/todolists/{id}/items/{item_id}** - Rejects duplicate title of another item (400 error) +- **PATCH /api/todolists/{id}/items/{item_id}/toggle** - Toggles completion status +- **DELETE /api/todolists/{id}/items/{item_id}** - Deletes an item (204 response) +- **POST /api/todolists/{id}/items/complete-all** - Enqueues async job (202 response) + +**Testing Strategy:** +- Uses `pytest` fixtures for test client and mocked services +- Mocks `TodoListService` and `TodoItemService` to isolate endpoint logic +- Tests HTTP status codes, response structure, and service method calls +- Covers success cases, validation errors, and not found scenarios +- Validates FastAPI's automatic request validation (422 errors) + +## Redis Configuration Details + +### Connection Settings +```python +# Redis for data storage (DB 1) +redis_conn = Redis( + host="host.docker.internal", # Docker host + port=6379, + db=1, # Database 1 for data + decode_responses=True # Automatic string decoding +) + +# Redis for job queue (DB 0) +redis_conn = Redis( + host="host.docker.internal", + port=6379, + db=0 # Database 0 for queue +) +``` + +**Why `host.docker.internal`?** +- Allows containers to connect to services on the host machine +- Redis runs in a separate container, accessible via Docker networking +- Alternative: Use service name `redis` if both services are in same compose file + +### Redis Database Separation + +- **DB 0:** Job queue and worker communication +- **DB 1:** Application data (TodoLists and TodoItems) + +This separation prevents queue operations from interfering with data storage. + +## CORS Configuration + +The API is configured to accept requests from any origin: +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +**Production Note:** Restrict `allow_origins` to specific domains in production. + +## Development Tools + +### Code Quality Tools + +- **Ruff:** Fast Python linter and formatter +- **MyPy:** Static type checker +- **Pytest:** Testing framework + +### VS Code Extensions + +The dev container automatically installs: +- Python extension +- Pylance (language server) +- Ruff (linting/formatting) +- MyPy type checker + +### Poetry Commands +```bash +# Add a new dependency +poetry add package-name + +# Add a dev dependency +poetry add --group dev package-name + +# Update dependencies +poetry update + +# Show installed packages +poetry show + +# Run a command in the virtual environment +poetry run python script.py +``` + +## Troubleshooting + +### Redis Connection Issues + +**Problem:** `ConnectionError: Error 111 connecting to redis:6379` + +**Solutions:** +1. Ensure Redis container is running: `docker ps` +2. Check Redis is accessible: `redis-cli ping` +3. Verify `host.docker.internal` resolves correctly +4. Check firewall settings + +### Worker Not Processing Jobs + +**Problem:** Jobs stay in "queued" status + +**Solutions:** +1. Ensure worker is running: `poetry run python -m app.worker` +2. Check worker logs for errors +3. Verify Redis connection in worker +4. Restart worker process + +### Port Already in Use + +**Problem:** Port 8000 is already in use + +**Solutions:** +1. Find process using port: `lsof -i :8000` +2. Kill the process or use a different port +3. Change port in uvicorn command: `--port 8001` + +### Poetry Install Fails + +**Problem:** Dependencies fail to install + +**Solutions:** +1. Update Poetry: `pip install --upgrade poetry` +2. Clear cache: `poetry cache clear pypi --all` +3. Delete `poetry.lock` and reinstall +4. Check Python version: `python --version` (should be 3.13) + +### Tests Failing + +**Problem:** Tests fail with Redis errors + +**Solutions:** +1. Ensure Redis is running and accessible +2. Clear Redis data: `redis-cli FLUSHALL` +3. Check test isolation (tests should clean up after themselves) +4. Run tests individually to identify issues + +## Environment Variables + +The application uses minimal environment configuration: +```bash +REDIS_URL=redis://redis:6379 # Set in docker-compose +``` + +For local development without Docker, you may need to adjust Redis connection strings in: +- `app/services/todo_lists.py` +- `app/services/todo_items.py` +- `app/redis_config.py` +- `app/worker.py` + +## Production Considerations + +Before deploying to production: + +1. **Security:** + - Restrict CORS origins + - Add authentication/authorization + - Use environment variables for sensitive config + - Enable HTTPS + +2. **Redis:** + - Use managed Redis service (AWS ElastiCache, Redis Cloud) + - Enable Redis persistence + - Configure password authentication + - Set up Redis clustering for high availability + +3. **Workers:** + - Run multiple worker instances + - Use supervisor or systemd for process management + - Monitor worker health + - Implement retry logic for failed jobs + +4. **Monitoring:** + - Add logging (structured logging) + - Set up error tracking (Sentry) + - Monitor Redis memory usage + - Track API performance metrics + +## Contributing + +When contributing to this project: + +1. Follow PEP 8 style guide (enforced by Ruff) +2. Add type hints to all functions +3. Write tests for new features +4. Update documentation as needed +5. Run linter and tests before committing: +```bash + poetry run ruff check . + poetry run mypy . + poetry run pytest +``` + +## API Response Examples + +### Create Todo List + +**Request:** +```bash +POST /api/todolists +Content-Type: application/json + +{ + "name": "Shopping List" +} +``` + +**Response (201 Created):** +```json +{ + "id": 1, + "name": "Shopping List", + "items": [] +} +``` + +### Create Todo Item + +**Request:** +```bash +POST /api/todolists/1/items +Content-Type: application/json + +{ + "title": "Buy milk", + "description": "2 gallons of whole milk", + "completed": false +} +``` + +**Response (201 Created):** +```json +{ + "id": 1, + "title": "Buy milk", + "description": "2 gallons of whole milk", + "completed": false +} +``` + +### Complete All Items (Async) + +**Request:** +```bash +POST /api/todolists/1/items/complete-all +``` + +**Response (202 Accepted):** +```json +{ + "message": "queued job", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "todo_list_id": 1, + "check_status": "/api/jobs/550e8400-e29b-41d4-a716-446655440000" +} +``` + +## Quick Start Summary + +1. **Open in Dev Container** (VS Code) +2. **Start FastAPI:** `poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` +3. **Start Worker:** `poetry run python -m app.worker` +4. **Visit:** http://localhost:8000/docs +5. **Run Tests:** `poetry run pytest` + + +Your backend is now ready to serve the React frontend! 🚀 + diff --git a/app/__init__.py b/app/__init__.py index 868c6d5..a8c8493 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -"""FastAPI Todo List application.""" +"""FastAPI Todo List application.""" diff --git a/app/main.py b/app/main.py index 90064ed..ee5e6a8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,26 +1,39 @@ -"""FastAPI application entry point.""" - -from fastapi import FastAPI - -from app.routers import todo_lists - -# Create FastAPI application instance -app = FastAPI( - title="TodoList API", - description="A simple Todo List API", - version="1.0.0", -) - -# Include routers -app.include_router(todo_lists.router) - - -@app.get("/", tags=["health"]) -async def root() -> dict[str, str]: - """ - Health check endpoint. - - Returns: - Simple message indicating the API is running - """ - return {"message": "TodoList API is running"} +"""FastAPI application entry point.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import jobs, todo_items, todo_lists + +# Create FastAPI application instance +app = FastAPI( + title="TodoList API", + description="A simple Todo List API", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "*", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(todo_lists.router) +app.include_router(todo_items.router) +app.include_router(jobs.router) + + +@app.get("/", tags=["health"]) +async def root() -> dict[str, str]: + """ + Health check endpoint. + + Returns: + Simple message indicating the API is running + """ + return {"message": "TodoList API is running"} diff --git a/app/models.py b/app/models.py index f1c2698..711727e 100644 --- a/app/models.py +++ b/app/models.py @@ -1,29 +1,64 @@ -"""Pydantic models for TodoList API.""" - -from pydantic import BaseModel, ConfigDict, Field - - -class TodoListBase(BaseModel): - """Base TodoList model with common attributes.""" - - name: str = Field(..., min_length=1, description="Name of the todo list") - - -class TodoListCreate(TodoListBase): - """Model for creating a new TodoList.""" - - pass - - -class TodoListUpdate(TodoListBase): - """Model for updating an existing TodoList.""" - - pass - - -class TodoList(TodoListBase): - """TodoList model with all attributes including ID.""" - - id: int = Field(..., description="Unique identifier for the todo list") - - model_config = ConfigDict(from_attributes=True) +"""Pydantic models for TodoList API.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# ===== TodoItem Models ===== +class TodoItemBase(BaseModel): + """Base TodoItem model with common attributes.""" + + title: str = Field(..., min_length=1, description="Title of the todo item") + description: Optional[str] = Field(default=None, description="Optional description") + completed: bool = Field(default=False, description="Completion status") + + +class TodoItemCreate(TodoItemBase): + """Model for creating a new TodoList.""" + + pass + + +class TodoItemUpdate(BaseModel): + """Model for updating a TodoItem.""" + + title: Optional[str] = Field(None, min_length=1, description="Title of the todo item") + description: Optional[str] = Field(None, description="Optional description") + completed: Optional[bool] = Field(None, description="Completion status") + + +class TodoItem(TodoItemBase): + """TodoList model with all attributes including ID.""" + + id: int = Field(..., description="Unique identifier for item") + + model_config = ConfigDict(from_attributes=True) + + +# ===== TodoList Models ===== +class TodoListBase(BaseModel): + """Base TodoList model with common attributes.""" + + name: str = Field(..., min_length=1, description="Name of the todo list") + + +class TodoListCreate(TodoListBase): + """Model for creating a new TodoList.""" + + pass + + +class TodoListUpdate(TodoListBase): + """Model for updating an existing TodoList.""" + + pass + + +class TodoList(TodoListBase): + """TodoList model with all attributes including ID.""" + + id: int = Field(..., description="Unique identifier for the todo list") + items: list[TodoItem] = Field(default_factory=list, description="Name of the todo item") + + model_config = ConfigDict(from_attributes=True) diff --git a/app/redis_config.py b/app/redis_config.py new file mode 100644 index 0000000..fae691d --- /dev/null +++ b/app/redis_config.py @@ -0,0 +1,16 @@ +"""Redis configuration for async task queue.""" + +import redis +from rq import Queue + +# Redis connection +redis_conn = redis.Redis(host="host.docker.internal", port=6379, db=0) +queue = Queue(connection=redis_conn) + + +def enqueue_complete_all(todo_list_id: int) -> str: + """Encuela el trabajo y devuelve ID.""" + from app.worker import complete_all_task + + job = queue.enqueue(complete_all_task, todo_list_id) + return job.id diff --git a/app/routers/__init__.py b/app/routers/__init__.py index f7ec5ce..e518366 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1 +1 @@ -"""API routers.""" +"""API routers.""" diff --git a/app/routers/jobs.py b/app/routers/jobs.py new file mode 100644 index 0000000..24af07a --- /dev/null +++ b/app/routers/jobs.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, HTTPException +from redis import Redis +from rq.job import Job + +router = APIRouter(prefix="/api/jobs", tags=["jobs"]) + + +@router.get("/{job_id}") +def get_job_status(job_id: str): + """View status of a job.""" + try: + redis_conn = Redis(host="host.docker.internal", port=6379, db=0) + job = Job.fetch(job_id, connection=redis_conn) + + return { + "id": job.id, + "status": job.get_status(), + "result": job.result, + "error": job.exc_info, + } + except: + raise HTTPException(404, "Job not found") diff --git a/app/routers/todo_items.py b/app/routers/todo_items.py new file mode 100644 index 0000000..6bbaf90 --- /dev/null +++ b/app/routers/todo_items.py @@ -0,0 +1,143 @@ +"""TodoItem API router with CRUD endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.models import TodoItem, TodoItemCreate, TodoItemUpdate +from app.redis_config import enqueue_complete_all +from app.services.todo_items import TodoItemService, get_todo_item_service + +router = APIRouter(prefix="/api/todolists/{todo_list_id}/items", tags=["items"]) + + +@router.get("", response_model=list[TodoItem], status_code=status.HTTP_200_OK) +async def index( + todo_list_id: int, + service: Annotated[TodoItemService, Depends(get_todo_item_service)], +) -> list[TodoItem]: + """Get all items from a todo list.""" + items = service.get_all(todo_list_id) + if items is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"TodoList with id {todo_list_id} not found", + ) + return items + + +@router.get("/{item_id}", response_model=TodoItem, status_code=status.HTTP_200_OK) +async def show( + todo_list_id: int, + item_id: int, + service: Annotated[TodoItemService, Depends(get_todo_item_service)], +) -> TodoItem: + """Get a specific item from a todo list.""" + item = service.get(todo_list_id, item_id) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found in TodoList {todo_list_id}", + ) + return item + + +@router.post("", response_model=TodoItem, status_code=status.HTTP_201_CREATED) +async def create( + todo_list_id: int, + item_data: TodoItemCreate, + service: Annotated[TodoItemService, Depends(get_todo_item_service)], +) -> TodoItem: + """Create a new item in a todo list.""" + existing_items = service.get_all(todo_list_id) + if existing_items: + for item in existing_items: + if item.title.lower() == item_data.title.lower(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"A task with title '{item_data.title}' already exists in this list", + ) + + item = service.create(todo_list_id, item_data) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"TodoList with id {todo_list_id} not found", + ) + return item + + +@router.put("/{item_id}", response_model=TodoItem, status_code=status.HTTP_200_OK) +async def update( + todo_list_id: int, + item_id: int, + item_data: TodoItemUpdate, + service: Annotated[TodoItemService, Depends(get_todo_item_service)], +) -> TodoItem: + """Update an existing item.""" + current_item = service.get(todo_list_id, item_id) + if current_item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found in TodoList {todo_list_id}", + ) + + existing_items = service.get_all(todo_list_id) + if existing_items: + for item in existing_items: + if item.id != item_id and item.title.lower() == item_data.title.lower(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"A task with title '{item_data.title}' already exists in this list", + ) + + item = service.update(todo_list_id, item_id, item_data) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found in TodoList {todo_list_id}", + ) + return item + + +@router.patch("/{item_id}/toggle", response_model=TodoItem, status_code=status.HTTP_200_OK) +async def toggle( + todo_list_id: int, + item_id: int, + service: Annotated[TodoItemService, Depends(get_todo_item_service)], +) -> TodoItem: + """Toggle the completion status of an item.""" + item = service.toggle(todo_list_id, item_id) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found in TodoList {todo_list_id}", + ) + return item + + +@router.post("/complete-all", status_code=202) +async def complete_all_async(todo_list_id: int) -> dict: + """Queues the task and responds immediately.""" + job_id = enqueue_complete_all(todo_list_id) + return { + "message": "queued job", + "job_id": job_id, + "todo_list_id": todo_list_id, + "check_status": f"/api/jobs/{job_id}", + } + + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + todo_list_id: int, + item_id: int, + service: Annotated[TodoItemService, Depends(get_todo_item_service)], +) -> None: + """Delete an item from a todo list.""" + deleted = service.delete(todo_list_id, item_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found in TodoList {todo_list_id}", + ) diff --git a/app/routers/todo_lists.py b/app/routers/todo_lists.py index 9360db4..07a768d 100644 --- a/app/routers/todo_lists.py +++ b/app/routers/todo_lists.py @@ -1,120 +1,114 @@ -"""TodoList API router with CRUD endpoints.""" - -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status - -from app.models import TodoList, TodoListCreate, TodoListUpdate -from app.services.todo_lists import TodoListService, get_todo_list_service - -router = APIRouter(prefix="/api/todolists", tags=["todolists"]) - - -@router.get("", response_model=list[TodoList], status_code=status.HTTP_200_OK) -async def index( - service: Annotated[TodoListService, Depends(get_todo_list_service)], -) -> list[TodoList]: - """ - Get all todo lists. - - Returns: - List of all TodoList objects - """ - return service.all() - - -@router.get("/{todo_list_id}", response_model=TodoList, status_code=status.HTTP_200_OK) -async def show( - todo_list_id: int, - service: Annotated[TodoListService, Depends(get_todo_list_service)], -) -> TodoList: - """ - Get a specific todo list by ID. - - Args: - todo_list_id: The ID of the todo list to retrieve - service: Injected TodoListService instance - - Returns: - TodoList object - - Raises: - HTTPException: 404 if todo list not found - """ - todo_list = service.get(todo_list_id) - if todo_list is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"TodoList with id {todo_list_id} not found", - ) - return todo_list - - -@router.post("", response_model=TodoList, status_code=status.HTTP_201_CREATED) -async def create( - todo_list_data: TodoListCreate, - service: Annotated[TodoListService, Depends(get_todo_list_service)], -) -> TodoList: - """ - Create a new todo list. - - Args: - todo_list_data: Data for creating the todo list - service: Injected TodoListService instance - - Returns: - The newly created TodoList object - """ - return service.create(todo_list_data) - - -@router.put("/{todo_list_id}", response_model=TodoList, status_code=status.HTTP_200_OK) -async def update( - todo_list_id: int, - todo_list_data: TodoListUpdate, - service: Annotated[TodoListService, Depends(get_todo_list_service)], -) -> TodoList: - """ - Update an existing todo list. - - Args: - todo_list_id: The ID of the todo list to update - todo_list_data: New data for the todo list - service: Injected TodoListService instance - - Returns: - Updated TodoList object - - Raises: - HTTPException: 404 if todo list not found - """ - updated_todo_list = service.update(todo_list_id, todo_list_data) - if updated_todo_list is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"TodoList with id {todo_list_id} not found", - ) - return updated_todo_list - - -@router.delete("/{todo_list_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete( - todo_list_id: int, - service: Annotated[TodoListService, Depends(get_todo_list_service)], -) -> None: - """ - Delete a todo list by ID. - - Args: - todo_list_id: The ID of the todo list to delete - service: Injected TodoListService instance - - Raises: - HTTPException: 404 if todo list not found - """ - deleted = service.delete(todo_list_id) - if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"TodoList with id {todo_list_id} not found", - ) +"""TodoList API router with CRUD endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.models import TodoList, TodoListCreate, TodoListUpdate +from app.services.todo_lists import TodoListService, get_todo_list_service + +router = APIRouter(prefix="/api/todolists", tags=["todolists"]) + + +@router.get("", response_model=list[TodoList], status_code=status.HTTP_200_OK) +async def index( + service: Annotated[TodoListService, Depends(get_todo_list_service)], +) -> list[TodoList]: + """ + Get all todo lists. + + Returns: + List of all TodoList objects + """ + return service.all() + + +@router.get("/{todo_list_id}", response_model=TodoList, status_code=status.HTTP_200_OK) +async def show( + todo_list_id: int, + service: Annotated[TodoListService, Depends(get_todo_list_service)], +) -> TodoList: + """ + Get a specific todo list by ID. + + Args: + todo_list_id: The ID of the todo list to retrieve + service: Injected TodoListService instance + + Returns: + TodoList object + + Raises: + HTTPException: 404 if todo list not found + """ + todo_list = service.get(todo_list_id) + if todo_list is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"TodoList with id {todo_list_id} not found", + ) + return todo_list + + +@router.post("", response_model=TodoList, status_code=status.HTTP_201_CREATED) +async def create( + todo_list_data: TodoListCreate, + service: Annotated[TodoListService, Depends(get_todo_list_service)], +) -> TodoList: + """ + Create a new todo list. + """ + try: + return service.create(todo_list_data) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.put("/{todo_list_id}", response_model=TodoList, status_code=status.HTTP_200_OK) +async def update( + todo_list_id: int, + todo_list_data: TodoListUpdate, + service: Annotated[TodoListService, Depends(get_todo_list_service)], +) -> TodoList: + """ + Update an existing todo list. + """ + try: + updated_todo_list = service.update(todo_list_id, todo_list_data) + if updated_todo_list is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"TodoList with id {todo_list_id} not found", + ) + return updated_todo_list + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.delete("/{todo_list_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + todo_list_id: int, + service: Annotated[TodoListService, Depends(get_todo_list_service)], +) -> None: + """ + Delete a todo list by ID. + + Args: + todo_list_id: The ID of the todo list to delete + service: Injected TodoListService instance + + Raises: + HTTPException: 404 if todo list not found + """ + deleted = service.delete(todo_list_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"TodoList with id {todo_list_id} not found", + ) diff --git a/app/services/__init__.py b/app/services/__init__.py index de2060f..500afd2 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1 +1 @@ -"""Business logic services.""" +"""Business logic services.""" diff --git a/app/services/todo_items.py b/app/services/todo_items.py new file mode 100644 index 0000000..94a3dad --- /dev/null +++ b/app/services/todo_items.py @@ -0,0 +1,230 @@ +"""TodoItem service for managing items within TodoLists.""" + +from typing import Optional + +from redis import Redis + +from app.models import TodoItem, TodoItemCreate, TodoItemUpdate +from app.services.todo_lists import get_todo_list_service + +# Connection to Redis for item counter (DB 1) +redis_conn = Redis(host="host.docker.internal", port=6379, db=1, decode_responses=True) + + +class TodoItemService: + """Service for managing TodoItems within TodoLists.""" + + def __init__(self) -> None: + """Initialize the service.""" + if not redis_conn.exists("todoitem:next_id"): + redis_conn.set("todoitem:next_id", 1) + + def _get_next_item_id(self, todo_list_id: int) -> int: + """Obtiene ID único para items en una lista específica.""" + key = f"todoitem:{todo_list_id}:next_id" + if not redis_conn.exists(key): + redis_conn.set(key, 1) + return redis_conn.incr(key) + + def _title_exists_in_list( + self, todo_list_id: int, title: str, exclude_item_id: Optional[int] = None + ) -> bool: + """ + Verifica si ya existe un item con ese título en la lista (case-insensitive). + + Args: + todo_list_id: ID de la lista + title: Título a verificar + exclude_item_id: ID of the item to exclude from the search (for updates) + + Returns: + True if the title already exists, False if not + """ + items = self.get_all(todo_list_id) + if items is None: + return False + + title_lower = title.strip().lower() + + for item in items: + if exclude_item_id is not None and item.id == exclude_item_id: + continue + + if item.title.lower() == title_lower: + return True + + return False + + def get_all(self, todo_list_id: int) -> Optional[list[TodoItem]]: + """Get all items from a todo list.""" + todo_list_service = get_todo_list_service() + todo_list = todo_list_service.get(todo_list_id) + if todo_list is None: + return None + return todo_list.items + + def get(self, todo_list_id: int, item_id: int) -> Optional[TodoItem]: + """Get a specific item from a todo list.""" + items = self.get_all(todo_list_id) + if items is None: + return None + for item in items: + if item.id == item_id: + return item + return None + + def create(self, todo_list_id: int, item_data: TodoItemCreate) -> Optional[TodoItem]: + """ + Create a new item in a todo list. + + Raises: + ValueError: Si el título ya existe en la lista + """ + todo_list_service = get_todo_list_service() + todo_list = todo_list_service.get(todo_list_id) + if todo_list is None: + return None + + # Validate duplicate title in list + if self._title_exists_in_list(todo_list_id, item_data.title): + raise ValueError(f"A task with title '{item_data.title}' already exists in this list") + + # Create new item with Redis ID + new_item = TodoItem( + id=self._get_next_item_id(todo_list_id), + title=item_data.title, + description=item_data.description, + completed=item_data.completed, + ) + + # add to list + todo_list.items.append(new_item) + + # save in redis + todo_list_service.save(todo_list) + return new_item + + def update( + self, todo_list_id: int, item_id: int, item_data: TodoItemUpdate + ) -> Optional[TodoItem]: + """ + Update an existing item. + + Raises: + ValueError: If the title already exists in the list + """ + todo_list_service = get_todo_list_service() + todo_list = todo_list_service.get(todo_list_id) + if todo_list is None: + return None + + # Buscar el item + for i, item in enumerate(todo_list.items): + if item.id == item_id: + new_title = item_data.title if item_data.title is not None else item.title + + # validate title + if new_title != item.title: + if self._title_exists_in_list(todo_list_id, new_title, exclude_item_id=item_id): + raise ValueError( + f"A task with title '{new_title}' already exists in this list" + ) + + # Update only provided fields + updated_item = TodoItem( + id=item.id, + title=new_title, + description=item_data.description + if item_data.description is not None + else item.description, + completed=item_data.completed + if item_data.completed is not None + else item.completed, + ) + # replace in list + todo_list.items[i] = updated_item + + # save in redis + todo_list_service.save(todo_list) + return updated_item + + return None + + def toggle(self, todo_list_id: int, item_id: int) -> Optional[TodoItem]: + """Toggle the completion status of an item.""" + todo_list_service = get_todo_list_service() + todo_list = todo_list_service.get(todo_list_id) + if todo_list is None: + return None + + # search item + for i, item in enumerate(todo_list.items): + if item.id == item_id: + # Create updated item + updated_item = TodoItem( + id=item.id, + title=item.title, + description=item.description, + completed=not item.completed, + ) + # Replace + todo_list.items[i] = updated_item + + # save in redis + todo_list_service.save(todo_list) + return updated_item + + return None + + def complete_all(self, todo_list_id: int) -> Optional[int]: + """Mark all incomplete items in a todo list as completed.""" + todo_list_service = get_todo_list_service() + todo_list = todo_list_service.get(todo_list_id) + if todo_list is None: + return None + + completed_count = 0 + for i, item in enumerate(todo_list.items): + if not item.completed: + updated_item = TodoItem( + id=item.id, + title=item.title, + description=item.description, + completed=True, + ) + todo_list.items[i] = updated_item + completed_count += 1 + + if completed_count > 0: + # save in redis + todo_list_service.save(todo_list) + + return completed_count + + def delete(self, todo_list_id: int, item_id: int) -> bool: + """Delete an item from a todo list.""" + todo_list_service = get_todo_list_service() + todo_list = todo_list_service.get(todo_list_id) + if todo_list is None: + return False + + # search and delete item + for i, item in enumerate(todo_list.items): + if item.id == item_id: + todo_list.items.pop(i) + # save in redis + todo_list_service.save(todo_list) + return True + + return False + + +# Global singleton instance +_todo_item_service = None + + +def get_todo_item_service(): + global _todo_item_service + if _todo_item_service is None: + _todo_item_service = TodoItemService() + return _todo_item_service diff --git a/app/services/todo_lists.py b/app/services/todo_lists.py index 1ca7c30..52c4eff 100644 --- a/app/services/todo_lists.py +++ b/app/services/todo_lists.py @@ -1,106 +1,166 @@ -"""TodoList service with in-memory storage.""" - -from typing import Optional - -from app.models import TodoList, TodoListCreate, TodoListUpdate - - -class TodoListService: - """Service for managing TodoLists with in-memory storage.""" - - def __init__(self) -> None: - """Initialize the service with empty storage.""" - self._storage: list[TodoList] = [] - self._next_id: int = 1 - - def all(self) -> list[TodoList]: - """ - Get all todo lists. - - Returns: - List of all TodoList objects - """ - return self._storage.copy() - - def get(self, todo_list_id: int) -> Optional[TodoList]: - """ - Get a specific todo list by ID. - - Args: - todo_list_id: The ID of the todo list to retrieve - - Returns: - TodoList object if found, None otherwise - """ - for todo_list in self._storage: - if todo_list.id == todo_list_id: - return todo_list - return None - - def create(self, todo_list_data: TodoListCreate) -> TodoList: - """ - Create a new todo list. - - Args: - todo_list_data: Data for creating the todo list - - Returns: - The newly created TodoList object - """ - new_todo_list = TodoList(id=self._next_id, name=todo_list_data.name) - self._storage.append(new_todo_list) - self._next_id += 1 - return new_todo_list - - def update(self, todo_list_id: int, todo_list_data: TodoListUpdate) -> Optional[TodoList]: - """ - Update an existing todo list. - - Args: - todo_list_id: The ID of the todo list to update - todo_list_data: New data for the todo list - - Returns: - Updated TodoList object if found, None otherwise - """ - for i, todo_list in enumerate(self._storage): - if todo_list.id == todo_list_id: - updated_todo_list = TodoList(id=todo_list_id, name=todo_list_data.name) - self._storage[i] = updated_todo_list - return updated_todo_list - return None - - def delete(self, todo_list_id: int) -> bool: - """ - Delete a todo list by ID. - - Args: - todo_list_id: The ID of the todo list to delete - - Returns: - True if deleted, False if not found - """ - for i, todo_list in enumerate(self._storage): - if todo_list.id == todo_list_id: - self._storage.pop(i) - return True - return False - - -# Global singleton instance -_todo_list_service: Optional[TodoListService] = None - - -def get_todo_list_service() -> TodoListService: - """ - Get or create the singleton TodoListService instance. - - This function is used for dependency injection in FastAPI. - - Returns: - The singleton TodoListService instance - """ - global _todo_list_service - if _todo_list_service is None: - _todo_list_service = TodoListService() - return _todo_list_service +"""TodoList service with Redis storage.""" + +import json +from typing import List, Optional + +from redis import Redis + +from app.models import TodoItem, TodoList, TodoListCreate, TodoListUpdate + +# Connecting to Redis (DB 1 for data, different from queue in DB 0) +redis_conn = Redis(host="host.docker.internal", port=6379, db=1, decode_responses=True) + + +class TodoListService: + """Service for managing TodoLists with Redis storage.""" + + def __init__(self) -> None: + """Initialize the service.""" + # Initialize ID counter if it does not exist + if not redis_conn.exists("todolist:next_id"): + redis_conn.set("todolist:next_id", 1) + + def _get_next_id(self) -> int: + """Gets and increments the next ID.""" + return redis_conn.incr("todolist:next_id") + + def _get_key(self, todo_list_id: int) -> str: + return f"todolist:{todo_list_id}" + + def _serialize(self, todo_list: TodoList) -> str: + """Convert TodoList to JSON for Redis.""" + return json.dumps( + { + "id": todo_list.id, + "name": todo_list.name, + "items": [item.model_dump() for item in todo_list.items], + } + ) + + def _deserialize(self, data: str) -> TodoList: + """Convierte JSON de Redis a TodoList.""" + obj = json.loads(data) + # Rebuild items as TodoItem objects + items = [TodoItem(**item) for item in obj["items"]] + return TodoList(id=obj["id"], name=obj["name"], items=items) + + def _name_exists(self, name: str, exclude_id: Optional[int] = None) -> bool: + """ + Checks if a list with that name already exists (case-insensitive). + + Args: + name: Name to verify + exclude_id: ID to exclude from the search (for updates) + + Returns: + True if the name already exists, False if not + """ + all_lists = self.all() + name_lower = name.strip().lower() + + for todo_list in all_lists: + # If we are updating, exclude the item that is being edited + if exclude_id is not None and todo_list.id == exclude_id: + continue + + if todo_list.name.lower() == name_lower: + return True + + return False + + def all(self) -> List[TodoList]: + """Get all todo lists.""" + keys = redis_conn.keys("todolist:*") + # Exclude counter key + keys = [key for key in keys if key != "todolist:next_id"] + + result = [] + for key in keys: + data = redis_conn.get(key) + if data: + result.append(self._deserialize(data)) + + return result + + def get(self, todo_list_id: int) -> Optional[TodoList]: + """Get a specific todo list by ID.""" + key = self._get_key(todo_list_id) + data = redis_conn.get(key) + + if not data: + return None + + return self._deserialize(data) + + def create(self, todo_list_data: TodoListCreate) -> TodoList: + """ + Create a new todo list. + + Raises: + ValueError: If name already exists + """ + # Validate duplicate name + if self._name_exists(todo_list_data.name): + raise ValueError(f"A list with the name '{todo_list_data.name} already exists'") + + new_id = self._get_next_id() + new_todo_list = TodoList( + id=new_id, + name=todo_list_data.name, + items=[], + ) + + # save in redis + key = self._get_key(new_id) + redis_conn.set(key, self._serialize(new_todo_list)) + + return new_todo_list + + def update(self, todo_list_id: int, todo_list_data: TodoListUpdate) -> Optional[TodoList]: + """ + Update an existing todo list. + + Raises: + ValueError: If name already exists + """ + existing = self.get(todo_list_id) + if not existing: + return None + + # Validate duplicate name (excluding current one) + if self._name_exists(todo_list_data.name, exclude_id=todo_list_id): + raise ValueError(f"A list with the name '{todo_list_data.name} already exists'") + + updated = TodoList( + id=todo_list_id, + name=todo_list_data.name, + items=existing.items, + ) + + # save in redis + key = self._get_key(todo_list_id) + redis_conn.set(key, self._serialize(updated)) + + return updated + + def delete(self, todo_list_id: int) -> bool: + """Delete a todo list by ID.""" + key = self._get_key(todo_list_id) + return redis_conn.delete(key) > 0 + + def save(self, todo_list: TodoList) -> None: + """Guarda una TodoList en Redis (para usar desde todo_items.py).""" + key = self._get_key(todo_list.id) + redis_conn.set(key, self._serialize(todo_list)) + + +# Global singleton instance +_todo_list_service: Optional[TodoListService] = None + + +def get_todo_list_service() -> TodoListService: + """Get or create the singleton TodoListService instance.""" + global _todo_list_service + if _todo_list_service is None: + _todo_list_service = TodoListService() + return _todo_list_service diff --git a/app/worker.py b/app/worker.py new file mode 100644 index 0000000..1cf3dfa --- /dev/null +++ b/app/worker.py @@ -0,0 +1,43 @@ +"""Worker for processing async tasks.""" + +from redis import Redis +from rq import Queue, Worker + +from app.services.todo_items import get_todo_item_service +from app.services.todo_lists import get_todo_list_service + + +def complete_all_task(todo_list_id: int) -> dict: + """Task that the worker executes.""" + service = get_todo_item_service() + todo_list_service = get_todo_list_service() + + todo_list = todo_list_service.get(todo_list_id) + if not todo_list: + return {"error": "List not found"} + + completed_count = 0 + for i, item in enumerate(todo_list.items): + if not item.completed: + todo_list.items[i] = item.__class__( + id=item.id, + title=item.title, + description=item.description, + completed=True, + ) + completed_count += 1 + + # save in redis + if completed_count > 0: + todo_list_service.save(todo_list) + + return {"completed": completed_count, "message": f"Completadas {completed_count} tareas"} + + +if __name__ == "__main__": + print("Worker started. Connecting to Redis...") + redis_conn = Redis(host="host.docker.internal", port=6379, db=0) + queue = Queue(connection=redis_conn) + worker = Worker([queue]) + print("Worker ready. Waiting for jobs...") + worker.work() diff --git a/docker-compose.yml b/docker-compose.yml index 07f592a..d7a5b42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,16 @@ -version: '3.8' -services: - app: - image: mcr.microsoft.com/devcontainers/python:1-3.13-bookworm - volumes: - - .:/workspaces/python-interview - ports: - - "8000:8000" - command: sleep infinity - working_dir: /workspaces/python-interview - networks: - - fastapi-network - -networks: - fastapi-network: - driver: bridge +version: '3.8' +services: + app: + image: mcr.microsoft.com/devcontainers/python:1-3.13-bookworm + volumes: + - .:/workspaces/python-interview + ports: + - "8000:8000" + command: sleep infinity + working_dir: /workspaces/python-interview + networks: + - fastapi-network + +networks: + fastapi-network: + driver: bridge diff --git a/mypy.ini b/mypy.ini index 575d10c..a715907 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,20 +1,20 @@ -[mypy] -python_version = 3.9 -strict = True -warn_return_any = True -warn_unused_configs = True -disallow_untyped_defs = True -disallow_any_unimported = False -no_implicit_optional = True -warn_redundant_casts = True -warn_unused_ignores = True -warn_no_return = True -check_untyped_defs = True -strict_equality = True -show_error_codes = True -show_column_numbers = True -pretty = True - -# Ignore missing imports for third-party libraries without stubs -[mypy-tests.*] -disallow_untyped_defs = False +[mypy] +python_version = 3.9 +strict = True +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_any_unimported = False +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +check_untyped_defs = True +strict_equality = True +show_error_codes = True +show_column_numbers = True +pretty = True + +# Ignore missing imports for third-party libraries without stubs +[mypy-tests.*] +disallow_untyped_defs = False diff --git a/poetry.toml b/poetry.toml index ab1033b..91902d8 100644 --- a/poetry.toml +++ b/poetry.toml @@ -1,2 +1,2 @@ -[virtualenvs] -in-project = true +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml index a505350..df7fbe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,75 +1,77 @@ -[tool.poetry] -name = "fastapi-todo" -version = "1.0.0" -description = "A simple Todo List API for Python/FastAPI candidates" -authors = ["Your Name "] -readme = "README.md" -packages = [{include = "app"}] - -[tool.poetry.dependencies] -python = "^3.9" -fastapi = "^0.115.0" -uvicorn = {extras = ["standard"], version = "^0.32.0"} -pydantic = "^2.9.0" -pydantic-settings = "^2.6.0" - -[tool.poetry.group.dev.dependencies] -pytest = "^8.3.0" -pytest-asyncio = "^0.24.0" -pytest-cov = "^6.0.0" -httpx = "^0.27.0" -ruff = "^0.7.0" -mypy = "^1.13.0" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" - -[tool.ruff] -line-length = 100 -target-version = "py39" - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "ARG", # flake8-unused-arguments - "SIM", # flake8-simplify -] -ignore = [] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" -skip-magic-trailing-comma = false - -[tool.mypy] -python_version = "3.9" -strict = true -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true -disallow_any_unimported = false -no_implicit_optional = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_no_return = true -check_untyped_defs = true -strict_equality = true - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -addopts = [ - "--strict-markers", - "--strict-config", - "-ra", -] +[tool.poetry] +name = "fastapi-todo" +version = "1.0.0" +description = "A simple Todo List API for Python/FastAPI candidates" +authors = ["Your Name "] +readme = "README.md" +packages = [{include = "app"}] + +[tool.poetry.dependencies] +python = "^3.9" +fastapi = "^0.115.0" +uvicorn = {extras = ["standard"], version = "^0.32.0"} +pydantic = "^2.9.0" +pydantic-settings = "^2.6.0" +redis = "~6.2.0" +rq = "^2.6.1" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.0" +pytest-asyncio = "^0.24.0" +pytest-cov = "^6.0.0" +httpx = "^0.27.0" +ruff = "^0.7.0" +mypy = "^1.13.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false + +[tool.mypy] +python_version = "3.9" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_any_unimported = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +check_untyped_defs = true +strict_equality = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "-ra", +] diff --git a/scripts/start_worker.py b/scripts/start_worker.py new file mode 100644 index 0000000..97ef33b --- /dev/null +++ b/scripts/start_worker.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Script to start the RQ worker.""" + +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from app.worker import start_worker + +if __name__ == "__main__": + print("Starting RQ worker...") + print("Press Ctrl+C to stop") + start_worker() diff --git a/tests/__init__.py b/tests/__init__.py index db49e82..d7e4b5b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Test suite.""" +"""Test suite.""" diff --git a/tests/test_todo_items.py b/tests/test_todo_items.py new file mode 100644 index 0000000..e3a44bc --- /dev/null +++ b/tests/test_todo_items.py @@ -0,0 +1,162 @@ +"""Unit tests for TodoItem API endpoints.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.models import TodoItem +from app.services.todo_items import get_todo_item_service + + +@pytest.fixture +def mock_item_service() -> Generator[MagicMock, None, None]: + """Mock TodoItemService para testing.""" + mock = MagicMock() + app.dependency_overrides[get_todo_item_service] = lambda: mock + yield mock + app.dependency_overrides.clear() + + +@pytest.fixture +def client() -> TestClient: + """Test client para FastAPI.""" + return TestClient(app) + + +class TestTodoItems: + """Tests concisos para endpoints de items.""" + + def test_index_returns_items(self, client: TestClient, mock_item_service: MagicMock) -> None: + """GET /items - Retorna todos los items de una lista.""" + mock_item_service.get_all.return_value = [ + TodoItem(id=1, title="Tarea 1", completed=False), + TodoItem(id=2, title="Tarea 2", completed=True), + ] + + response = client.get("/api/todolists/1/items") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Tarea 1" + mock_item_service.get_all.assert_called_once_with(1) + + def test_show_returns_single_item( + self, client: TestClient, mock_item_service: MagicMock + ) -> None: + """GET /items/{id} - Returns a specific item.""" + mock_item_service.get.return_value = TodoItem(id=1, title="Tarea", completed=False) + + response = client.get("/api/todolists/1/items/1") + + assert response.status_code == 200 + assert response.json()["title"] == "Tarea" + mock_item_service.get.assert_called_once_with(1, 1) + + def test_show_returns_404_when_not_found( + self, client: TestClient, mock_item_service: MagicMock + ) -> None: + """GET /items/{id} - Returns 404 when it does not exist.""" + mock_item_service.get.return_value = None + + response = client.get("/api/todolists/1/items/999") + + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_create_item_success(self, client: TestClient, mock_item_service: MagicMock) -> None: + """POST /items - Create a new item.""" + mock_item_service.get_all.return_value = [] + mock_item_service.create.return_value = TodoItem(id=1, title="Nueva tarea", completed=False) + + response = client.post( + "/api/todolists/1/items", json={"title": "Nueva tarea", "completed": False} + ) + + assert response.status_code == 201 + assert response.json()["title"] == "Nueva tarea" + mock_item_service.create.assert_called_once() + + def test_create_rejects_duplicate_title( + self, client: TestClient, mock_item_service: MagicMock + ) -> None: + """POST /items - Reject duplicate titles.""" + existing_item = TodoItem(id=1, title="Tarea Existente", completed=False) + mock_item_service.get_all.return_value = [existing_item] + + response = client.post( + "/api/todolists/1/items", json={"title": "Tarea Existente", "completed": False} + ) + + assert response.status_code == 400 + assert "already exists" in response.json()["detail"].lower() + mock_item_service.create.assert_not_called() + + def test_update_item_success(self, client: TestClient, mock_item_service: MagicMock) -> None: + """PUT /items/{id} - Update an existing item.""" + current_item = TodoItem(id=1, title="Viejo", completed=False) + updated_item = TodoItem(id=1, title="Nuevo", completed=True) + + mock_item_service.get.return_value = current_item + mock_item_service.get_all.return_value = [current_item] # Solo este item existe + mock_item_service.update.return_value = updated_item + + response = client.put( + "/api/todolists/1/items/1", json={"title": "Nuevo", "completed": True} + ) + + assert response.status_code == 200 + assert response.json()["title"] == "Nuevo" + assert response.json()["completed"] is True + + def test_update_rejects_duplicate_title( + self, client: TestClient, mock_item_service: MagicMock + ) -> None: + """PUT /items/{id} - Reject duplicate title of another item.""" + current_item = TodoItem(id=1, title="Actual", completed=False) + other_item = TodoItem(id=2, title="Otro", completed=True) + + mock_item_service.get.return_value = current_item + mock_item_service.get_all.return_value = [current_item, other_item] + + response = client.put("/api/todolists/1/items/1", json={"title": "Otro", "completed": True}) + + assert response.status_code == 400 + assert "already exists" in response.json()["detail"].lower() + mock_item_service.update.assert_not_called() + + def test_toggle_item_completion(self, client: TestClient, mock_item_service: MagicMock) -> None: + """PATCH /items/{id}/toggle - Change completed status.""" + toggled_item = TodoItem(id=1, title="Tarea", completed=True) + mock_item_service.toggle.return_value = toggled_item + + response = client.patch("/api/todolists/1/items/1/toggle") + + assert response.status_code == 200 + assert response.json()["completed"] is True + mock_item_service.toggle.assert_called_once_with(1, 1) + + def test_delete_item_success(self, client: TestClient, mock_item_service: MagicMock) -> None: + """DELETE /items/{id} - Delete an item.""" + mock_item_service.delete.return_value = True + + response = client.delete("/api/todolists/1/items/1") + + assert response.status_code == 204 + mock_item_service.delete.assert_called_once_with(1, 1) + + def test_complete_all_async_enqueues_job(self, client: TestClient) -> None: + """POST /items/complete-all - Queue asynchronous work.""" + with patch("app.routers.todo_items.enqueue_complete_all") as mock_enqueue: + mock_enqueue.return_value = "job-123" + + response = client.post("/api/todolists/1/items/complete-all") + + assert response.status_code == 202 + data = response.json() + assert data["message"] == "queued job" + assert data["job_id"] == "job-123" + mock_enqueue.assert_called_once_with(1) diff --git a/tests/test_todo_lists.py b/tests/test_todo_lists.py index 62bd1fd..b479ea1 100644 --- a/tests/test_todo_lists.py +++ b/tests/test_todo_lists.py @@ -1,221 +1,221 @@ -"""Unit tests for TodoList API endpoints.""" - -from collections.abc import Generator -from unittest.mock import MagicMock - -import pytest -from fastapi.testclient import TestClient - -from app.main import app -from app.models import TodoList -from app.services.todo_lists import get_todo_list_service - - -@pytest.fixture -def mock_service() -> Generator[MagicMock, None, None]: - """ - Create a mock TodoListService for testing. - - Yields: - Mock service instance - """ - mock = MagicMock() - - # Override the dependency - def override_get_service() -> MagicMock: - return mock - - app.dependency_overrides[get_todo_list_service] = override_get_service - yield mock - app.dependency_overrides.clear() - - -@pytest.fixture -def client() -> TestClient: - """ - Create a test client for the FastAPI app. - - Returns: - TestClient instance - """ - return TestClient(app) - - -class TestIndex: - """Tests for GET /api/todolists endpoint.""" - - def test_returns_all_todo_lists(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that index returns all todo lists.""" - # Arrange - expected_todos = [ - TodoList(id=1, name="First list"), - TodoList(id=2, name="Second list"), - ] - mock_service.all.return_value = expected_todos - - # Act - response = client.get("/api/todolists") - - # Assert - assert response.status_code == 200 - assert len(response.json()) == 2 - assert response.json()[0]["id"] == 1 - assert response.json()[0]["name"] == "First list" - assert response.json()[1]["id"] == 2 - assert response.json()[1]["name"] == "Second list" - mock_service.all.assert_called_once() - - def test_returns_empty_list_when_no_todos( - self, client: TestClient, mock_service: MagicMock - ) -> None: - """Test that index returns empty list when no todos exist.""" - # Arrange - mock_service.all.return_value = [] - - # Act - response = client.get("/api/todolists") - - # Assert - assert response.status_code == 200 - assert response.json() == [] - mock_service.all.assert_called_once() - - -class TestShow: - """Tests for GET /api/todolists/{id} endpoint.""" - - def test_returns_todo_list_by_id(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that show returns a specific todo list.""" - # Arrange - expected_todo = TodoList(id=1, name="Test list") - mock_service.get.return_value = expected_todo - - # Act - response = client.get("/api/todolists/1") - - # Assert - assert response.status_code == 200 - assert response.json()["id"] == 1 - assert response.json()["name"] == "Test list" - mock_service.get.assert_called_once_with(1) - - def test_returns_404_when_not_found(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that show returns 404 when todo list doesn't exist.""" - # Arrange - mock_service.get.return_value = None - - # Act - response = client.get("/api/todolists/999") - - # Assert - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - mock_service.get.assert_called_once_with(999) - - -class TestCreate: - """Tests for POST /api/todolists endpoint.""" - - def test_creates_new_todo_list(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that create successfully creates a new todo list.""" - # Arrange - created_todo = TodoList(id=1, name="New list") - mock_service.create.return_value = created_todo - - # Act - response = client.post("/api/todolists", json={"name": "New list"}) - - # Assert - assert response.status_code == 201 - assert response.json()["id"] == 1 - assert response.json()["name"] == "New list" - mock_service.create.assert_called_once() - - def test_validates_required_fields(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that create validates required fields.""" - # Act - response = client.post("/api/todolists", json={}) - - # Assert - assert response.status_code == 422 - mock_service.create.assert_not_called() - - def test_validates_name_not_empty(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that create validates name is not empty.""" - # Act - response = client.post("/api/todolists", json={"name": ""}) - - # Assert - assert response.status_code == 422 - mock_service.create.assert_not_called() - - -class TestUpdate: - """Tests for PUT /api/todolists/{id} endpoint.""" - - def test_updates_existing_todo_list(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that update successfully updates an existing todo list.""" - # Arrange - updated_todo = TodoList(id=1, name="Updated list") - mock_service.update.return_value = updated_todo - - # Act - response = client.put("/api/todolists/1", json={"name": "Updated list"}) - - # Assert - assert response.status_code == 200 - assert response.json()["id"] == 1 - assert response.json()["name"] == "Updated list" - mock_service.update.assert_called_once() - - def test_returns_404_when_not_found(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that update returns 404 when todo list doesn't exist.""" - # Arrange - mock_service.update.return_value = None - - # Act - response = client.put("/api/todolists/999", json={"name": "Updated"}) - - # Assert - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - mock_service.update.assert_called_once() - - def test_validates_required_fields(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that update validates required fields.""" - # Act - response = client.put("/api/todolists/1", json={}) - - # Assert - assert response.status_code == 422 - mock_service.update.assert_not_called() - - -class TestDelete: - """Tests for DELETE /api/todolists/{id} endpoint.""" - - def test_deletes_existing_todo_list(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that delete successfully deletes an existing todo list.""" - # Arrange - mock_service.delete.return_value = True - - # Act - response = client.delete("/api/todolists/1") - - # Assert - assert response.status_code == 204 - assert response.content == b"" - mock_service.delete.assert_called_once_with(1) - - def test_returns_404_when_not_found(self, client: TestClient, mock_service: MagicMock) -> None: - """Test that delete returns 404 when todo list doesn't exist.""" - # Arrange - mock_service.delete.return_value = False - - # Act - response = client.delete("/api/todolists/999") - - # Assert - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - mock_service.delete.assert_called_once_with(999) +"""Unit tests for TodoList API endpoints.""" + +from collections.abc import Generator +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.models import TodoList +from app.services.todo_lists import get_todo_list_service + + +@pytest.fixture +def mock_service() -> Generator[MagicMock, None, None]: + """ + Create a mock TodoListService for testing. + + Yields: + Mock service instance + """ + mock = MagicMock() + + # Override the dependency + def override_get_service() -> MagicMock: + return mock + + app.dependency_overrides[get_todo_list_service] = override_get_service + yield mock + app.dependency_overrides.clear() + + +@pytest.fixture +def client() -> TestClient: + """ + Create a test client for the FastAPI app. + + Returns: + TestClient instance + """ + return TestClient(app) + + +class TestIndex: + """Tests for GET /api/todolists endpoint.""" + + def test_returns_all_todo_lists(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that index returns all todo lists.""" + # Arrange + expected_todos = [ + TodoList(id=1, name="First list"), + TodoList(id=2, name="Second list"), + ] + mock_service.all.return_value = expected_todos + + # Act + response = client.get("/api/todolists") + + # Assert + assert response.status_code == 200 + assert len(response.json()) == 2 + assert response.json()[0]["id"] == 1 + assert response.json()[0]["name"] == "First list" + assert response.json()[1]["id"] == 2 + assert response.json()[1]["name"] == "Second list" + mock_service.all.assert_called_once() + + def test_returns_empty_list_when_no_todos( + self, client: TestClient, mock_service: MagicMock + ) -> None: + """Test that index returns empty list when no todos exist.""" + # Arrange + mock_service.all.return_value = [] + + # Act + response = client.get("/api/todolists") + + # Assert + assert response.status_code == 200 + assert response.json() == [] + mock_service.all.assert_called_once() + + +class TestShow: + """Tests for GET /api/todolists/{id} endpoint.""" + + def test_returns_todo_list_by_id(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that show returns a specific todo list.""" + # Arrange + expected_todo = TodoList(id=1, name="Test list") + mock_service.get.return_value = expected_todo + + # Act + response = client.get("/api/todolists/1") + + # Assert + assert response.status_code == 200 + assert response.json()["id"] == 1 + assert response.json()["name"] == "Test list" + mock_service.get.assert_called_once_with(1) + + def test_returns_404_when_not_found(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that show returns 404 when todo list doesn't exist.""" + # Arrange + mock_service.get.return_value = None + + # Act + response = client.get("/api/todolists/999") + + # Assert + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + mock_service.get.assert_called_once_with(999) + + +class TestCreate: + """Tests for POST /api/todolists endpoint.""" + + def test_creates_new_todo_list(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that create successfully creates a new todo list.""" + # Arrange + created_todo = TodoList(id=1, name="New list") + mock_service.create.return_value = created_todo + + # Act + response = client.post("/api/todolists", json={"name": "New list"}) + + # Assert + assert response.status_code == 201 + assert response.json()["id"] == 1 + assert response.json()["name"] == "New list" + mock_service.create.assert_called_once() + + def test_validates_required_fields(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that create validates required fields.""" + # Act + response = client.post("/api/todolists", json={}) + + # Assert + assert response.status_code == 422 + mock_service.create.assert_not_called() + + def test_validates_name_not_empty(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that create validates name is not empty.""" + # Act + response = client.post("/api/todolists", json={"name": ""}) + + # Assert + assert response.status_code == 422 + mock_service.create.assert_not_called() + + +class TestUpdate: + """Tests for PUT /api/todolists/{id} endpoint.""" + + def test_updates_existing_todo_list(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that update successfully updates an existing todo list.""" + # Arrange + updated_todo = TodoList(id=1, name="Updated list") + mock_service.update.return_value = updated_todo + + # Act + response = client.put("/api/todolists/1", json={"name": "Updated list"}) + + # Assert + assert response.status_code == 200 + assert response.json()["id"] == 1 + assert response.json()["name"] == "Updated list" + mock_service.update.assert_called_once() + + def test_returns_404_when_not_found(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that update returns 404 when todo list doesn't exist.""" + # Arrange + mock_service.update.return_value = None + + # Act + response = client.put("/api/todolists/999", json={"name": "Updated"}) + + # Assert + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + mock_service.update.assert_called_once() + + def test_validates_required_fields(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that update validates required fields.""" + # Act + response = client.put("/api/todolists/1", json={}) + + # Assert + assert response.status_code == 422 + mock_service.update.assert_not_called() + + +class TestDelete: + """Tests for DELETE /api/todolists/{id} endpoint.""" + + def test_deletes_existing_todo_list(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that delete successfully deletes an existing todo list.""" + # Arrange + mock_service.delete.return_value = True + + # Act + response = client.delete("/api/todolists/1") + + # Assert + assert response.status_code == 204 + assert response.content == b"" + mock_service.delete.assert_called_once_with(1) + + def test_returns_404_when_not_found(self, client: TestClient, mock_service: MagicMock) -> None: + """Test that delete returns 404 when todo list doesn't exist.""" + # Arrange + mock_service.delete.return_value = False + + # Act + response = client.delete("/api/todolists/999") + + # Assert + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + mock_service.delete.assert_called_once_with(999)