diff --git a/PLANNING.md b/PLANNING.md new file mode 100644 index 0000000..3c0f7b2 --- /dev/null +++ b/PLANNING.md @@ -0,0 +1,74 @@ +# Todo API — Planning Document + +## Overview + +A lightweight RESTful Todo API built with **FastAPI** and backed by an +in-memory Python dictionary. Designed for learning, prototyping, and +automated testing — not for production persistence. + +## Data Model + +### Todo + +| Field | Type | Default | Notes | +|--------------|-----------------|-----------------|------------------------------| +| id | int | auto-increment | Primary key, assigned by store | +| title | str | *(required)* | Must be at least 1 character | +| description | Optional[str] | None | Free-text description | +| completed | bool | False | Completion status | +| created_at | str (ISO 8601) | UTC now | Set once at creation time | + +## API Endpoints + +| Method | Path | Request Body | Success Status | Response Body | +|--------|-------------------|--------------|----------------|----------------------| +| GET | `/` | — | 200 | `{"message": "..."}` | +| POST | `/todos` | TodoCreate | 201 | TodoResponse | +| GET | `/todos` | — | 200 | List[TodoResponse] | +| GET | `/todos/{todo_id}`| — | 200 | TodoResponse | +| PUT | `/todos/{todo_id}`| TodoUpdate | 200 | TodoResponse | +| DELETE | `/todos/{todo_id}`| — | 204 | *(empty)* | + +### Error Responses + +- **404 Not Found** — returned by GET, PUT, DELETE when `todo_id` does + not exist. Body: `{"detail": "Todo not found"}`. +- **422 Unprocessable Entity** — returned automatically by FastAPI when + the request body fails Pydantic validation. + +## Project Structure + +``` +. +├── main.py # FastAPI app creation, router mounting, uvicorn entry point +├── routes.py # APIRouter with all five CRUD endpoints +├── models.py # Pydantic request/response schemas +├── storage.py # In-memory TodoStore class +├── requirements.txt # Python dependency pins +├── conftest.py # Root pytest configuration +├── tests/ +│ ├── test_main.py # Tests for the root endpoint and app wiring +│ └── ... # Additional test modules +├── PLANNING.md # This file +└── RUNNING.md # How to install and run +``` + +## Design Decisions + +1. **In-memory storage** — chosen for simplicity; no external database + dependency. The `TodoStore` class encapsulates all state so it can + be swapped for a persistent backend later. + +2. **Auto-incrementing integer IDs** — simple, predictable, easy to test. + A production system might use UUIDs. + +3. **Module-level store instance in routes.py** — the store is + instantiated once when the module is imported. Tests reset it via + `store.reset()` to ensure isolation. + +4. **Partial updates via PUT with optional fields** — `TodoUpdate` has + all-optional fields; only non-`None` values are applied. This keeps + the endpoint count low while supporting partial changes. + +5. **204 No Content for DELETE** — follows REST conventions; the + response body is empty on successful deletion. diff --git a/RUNNING.md b/RUNNING.md index 77896cf..7787c41 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -7,7 +7,7 @@ ## Install dependencies ```bash -pip install fastapi uvicorn pydantic +pip install -r requirements.txt ``` For running the test suite you will also need: @@ -18,14 +18,38 @@ pip install httpx pytest ## Start the server +```bash +uvicorn main:app --reload +``` + +By default the server binds to `127.0.0.1:8000`. You can customise the +host and port: + ```bash uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` +Alternatively, run the application directly with Python: + +```bash +python main.py +``` + The API will be available at . Interactive docs are served at . +## API Endpoints + +| Method | Path | Description | +|--------|-------------------|-----------------------| +| GET | `/` | Health / welcome page | +| POST | `/todos` | Create a new todo | +| GET | `/todos` | List all todos | +| GET | `/todos/{todo_id}`| Get a single todo | +| PUT | `/todos/{todo_id}`| Update a todo | +| DELETE | `/todos/{todo_id}`| Delete a todo | + ## Run the tests ```bash diff --git a/SETUP.md b/SETUP.md index 643c59c..47a53e1 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,25 +1,28 @@ # Setup Instructions +## Prerequisites + +- Python 3.10 or later + ## Install Dependencies ```bash +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate pip install -r requirements.txt ``` -## Install Test Dependencies +## Run the Server ```bash -pip install pytest httpx +uvicorn main:app --reload ``` +The API will be available at http://127.0.0.1:8000. +Interactive docs at http://127.0.0.1:8000/docs. + ## Run Tests ```bash pytest tests/ -v ``` - -## Run the Application - -```bash -uvicorn main:app --reload -``` diff --git a/main.py b/main.py index 2e8fbda..edf4ac9 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ from __future__ import annotations +import uvicorn from fastapi import FastAPI from routes import router @@ -21,4 +22,8 @@ @app.get("/", tags=["root"]) async def root() -> dict: """Return a welcome message at the API root.""" - return {"message": "Welcome to the Todo API"} + return {"message": "Todo API is running"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/models.py b/models.py index 28ccca3..55071e2 100644 --- a/models.py +++ b/models.py @@ -8,7 +8,6 @@ from __future__ import annotations -from datetime import datetime from typing import Optional from pydantic import BaseModel, Field diff --git a/requirements.txt b/requirements.txt index 5a6ebf7..eca82c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,3 @@ -fastapi>=0.100.0 -uvicorn>=0.23.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 pydantic>=2.0.0 -pytest>=7.0.0 -pytest-timeout>=2.1.0 -httpx>=0.24.0 diff --git a/routes.py b/routes.py index e7b8744..34b6398 100644 --- a/routes.py +++ b/routes.py @@ -13,7 +13,7 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Response from models import TodoCreate, TodoResponse, TodoUpdate from storage import TodoStore @@ -75,14 +75,16 @@ async def update_todo(todo_id: int, payload: TodoUpdate) -> TodoResponse: return TodoResponse(**todo) -@router.delete("/todos/{todo_id}", status_code=200, tags=["todos"]) -async def delete_todo(todo_id: int) -> dict: +@router.delete("/todos/{todo_id}", status_code=204, tags=["todos"]) +async def delete_todo(todo_id: int) -> Response: """Delete a todo item by its ID. + Returns 204 No Content on success. + Raises: HTTPException: 404 if the todo is not found. """ deleted = store.delete(todo_id) if not deleted: raise HTTPException(status_code=404, detail="Todo not found") - return {"detail": "Todo deleted successfully"} + return Response(status_code=204) diff --git a/storage.py b/storage.py index 45eebca..6895d9f 100644 --- a/storage.py +++ b/storage.py @@ -60,29 +60,26 @@ def add( "created_at": now, } self._todos[todo_id] = todo - return dict(todo) + return todo - def get(self, todo_id: int) -> Optional[dict]: - """Retrieve a single todo by its ID. - - Args: - todo_id: The integer ID of the todo. + def get_all(self) -> List[dict]: + """Return a list of all stored todos. Returns: - A copy of the todo dict, or None if not found. + A list of todo dictionaries ordered by insertion. """ - todo = self._todos.get(todo_id) - if todo is None: - return None - return dict(todo) + return list(self._todos.values()) - def get_all(self) -> List[dict]: - """Return a list of all todos ordered by ID ascending. + def get(self, todo_id: int) -> Optional[dict]: + """Retrieve a single todo by its ID. + + Args: + todo_id: The unique identifier of the todo. Returns: - A list of todo dictionaries (copies). + The todo dictionary, or ``None`` if not found. """ - return [dict(t) for t in self._todos.values()] + return self._todos.get(todo_id) def update( self, @@ -91,38 +88,40 @@ def update( description: Optional[str] = None, completed: Optional[bool] = None, ) -> Optional[dict]: - """Update fields of an existing todo. + """Update an existing todo with the supplied fields. - Only non-None arguments are applied. + Only non-``None`` arguments are applied, allowing partial updates. Args: - todo_id: The integer ID of the todo to update. + todo_id: The unique identifier of the todo to update. title: New title (if provided). description: New description (if provided). completed: New completion status (if provided). Returns: - A copy of the updated todo dict, or None if not found. + The updated todo dictionary, or ``None`` if the ID was not found. """ todo = self._todos.get(todo_id) if todo is None: return None + if title is not None: todo["title"] = title if description is not None: todo["description"] = description if completed is not None: todo["completed"] = completed - return dict(todo) + + return todo def delete(self, todo_id: int) -> bool: """Remove a todo by its ID. Args: - todo_id: The integer ID of the todo to delete. + todo_id: The unique identifier of the todo to remove. Returns: - True if the todo was found and deleted, False otherwise. + ``True`` if the todo was found and removed, ``False`` otherwise. """ if todo_id in self._todos: del self._todos[todo_id] diff --git a/store.py b/store.py new file mode 100644 index 0000000..675fae5 --- /dev/null +++ b/store.py @@ -0,0 +1,125 @@ +"""Module-level functional interface to in-memory Todo storage. + +Wraps a module-level ``dict`` and auto-incrementing counter, exposing +plain functions for CRUD operations. This complements the class-based +``TodoStore`` in ``storage.py`` and serves as the store layer described +in the project specification. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Dict, List, Optional + +# --------------------------------------------------------------------------- +# Module-level state +# --------------------------------------------------------------------------- + +todos: Dict[int, dict] = {} +_counter: int = 0 + + +# --------------------------------------------------------------------------- +# CRUD helpers +# --------------------------------------------------------------------------- + + +def create_todo(todo_data: dict) -> dict: + """Create a new todo item and return its full representation. + + ``todo_data`` should contain at least a ``title`` key. Optional keys: + ``description`` (defaults to ``None``) and ``completed`` (defaults to + ``False``). + + Args: + todo_data: Dictionary with the fields for the new todo. + + Returns: + A dictionary representing the newly created todo, including the + auto-generated ``id`` and ``created_at`` timestamp. + """ + global _counter # noqa: PLW0603 + _counter += 1 + todo_id = _counter + now = datetime.now(timezone.utc).isoformat() + todo: dict = { + "id": todo_id, + "title": todo_data["title"], + "description": todo_data.get("description"), + "completed": todo_data.get("completed", False), + "created_at": now, + } + todos[todo_id] = todo + return dict(todo) + + +def get_all_todos() -> List[dict]: + """Return a list of all stored todo items. + + Returns: + A list of todo dictionaries (copies). + """ + return [dict(t) for t in todos.values()] + + +def get_todo_by_id(todo_id: int) -> Optional[dict]: + """Retrieve a single todo by its ID. + + Args: + todo_id: The integer ID of the todo. + + Returns: + A copy of the todo dict, or ``None`` if not found. + """ + todo = todos.get(todo_id) + if todo is None: + return None + return dict(todo) + + +def update_todo(todo_id: int, update_data: dict) -> Optional[dict]: + """Partially update an existing todo. + + Only keys present in *update_data* whose values are not ``None`` are + applied. + + Args: + todo_id: The integer ID of the todo to update. + update_data: Dictionary of fields to change. + + Returns: + The updated todo dict, or ``None`` if the ID does not exist. + """ + todo = todos.get(todo_id) + if todo is None: + return None + for key in ("title", "description", "completed"): + if key in update_data and update_data[key] is not None: + todo[key] = update_data[key] + return dict(todo) + + +def delete_todo(todo_id: int) -> bool: + """Remove a todo by its ID. + + Args: + todo_id: The integer ID of the todo to delete. + + Returns: + ``True`` if the todo was found and deleted, ``False`` otherwise. + """ + if todo_id in todos: + del todos[todo_id] + return True + return False + + +def reset_store() -> None: + """Clear all todos and reset the auto-increment counter. + + Intended for use in test fixtures to guarantee a clean state between + test cases. + """ + global _counter # noqa: PLW0603 + todos.clear() + _counter = 0 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..61d8b74 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,180 @@ +"""Integration tests for the Todo API endpoints.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from main import app +from routes import store + + +@pytest.fixture(autouse=True) +def _reset_store() -> None: + """Reset the shared store before each test for isolation.""" + store.reset() + + +client = TestClient(app) + + +# --------------------------------------------------------------------------- +# Root +# --------------------------------------------------------------------------- + + +class TestRoot: + """Tests for the root endpoint.""" + + def test_root_returns_message(self) -> None: + """GET / should return the welcome message.""" + resp = client.get("/") + assert resp.status_code == 200 + assert resp.json() == {"message": "Todo API is running"} + + +# --------------------------------------------------------------------------- +# POST /todos +# --------------------------------------------------------------------------- + + +class TestCreateTodo: + """Tests for creating a todo.""" + + def test_create_minimal(self) -> None: + """POST with only a title should succeed with defaults.""" + resp = client.post("/todos", json={"title": "Buy milk"}) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "Buy milk" + assert data["description"] is None + assert data["completed"] is False + assert "id" in data + assert "created_at" in data + + def test_create_full(self) -> None: + """POST with all fields should apply them.""" + resp = client.post( + "/todos", + json={ + "title": "Deploy", + "description": "Push to prod", + "completed": True, + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "Deploy" + assert data["description"] == "Push to prod" + assert data["completed"] is True + + def test_create_empty_title_rejected(self) -> None: + """POST with an empty title should return 422.""" + resp = client.post("/todos", json={"title": ""}) + assert resp.status_code == 422 + + def test_create_missing_title_rejected(self) -> None: + """POST without a title should return 422.""" + resp = client.post("/todos", json={}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /todos +# --------------------------------------------------------------------------- + + +class TestListTodos: + """Tests for listing todos.""" + + def test_empty_list(self) -> None: + """GET /todos on an empty store should return [].""" + resp = client.get("/todos") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_multiple(self) -> None: + """GET /todos should return all created todos.""" + client.post("/todos", json={"title": "A"}) + client.post("/todos", json={"title": "B"}) + resp = client.get("/todos") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +# --------------------------------------------------------------------------- +# GET /todos/{todo_id} +# --------------------------------------------------------------------------- + + +class TestGetTodo: + """Tests for retrieving a single todo.""" + + def test_get_existing(self) -> None: + """GET /todos/{id} for a valid id should return the todo.""" + create_resp = client.post("/todos", json={"title": "Find me"}) + todo_id = create_resp.json()["id"] + resp = client.get(f"/todos/{todo_id}") + assert resp.status_code == 200 + assert resp.json()["title"] == "Find me" + + def test_get_nonexistent(self) -> None: + """GET /todos/{id} for an unknown id should return 404.""" + resp = client.get("/todos/999") + assert resp.status_code == 404 + assert resp.json()["detail"] == "Todo not found" + + +# --------------------------------------------------------------------------- +# PUT /todos/{todo_id} +# --------------------------------------------------------------------------- + + +class TestUpdateTodo: + """Tests for updating a todo.""" + + def test_update_title(self) -> None: + """PUT should update only the supplied field.""" + create_resp = client.post("/todos", json={"title": "Old"}) + todo_id = create_resp.json()["id"] + resp = client.put(f"/todos/{todo_id}", json={"title": "New"}) + assert resp.status_code == 200 + assert resp.json()["title"] == "New" + + def test_update_completed(self) -> None: + """PUT should toggle completed status.""" + create_resp = client.post("/todos", json={"title": "Toggle"}) + todo_id = create_resp.json()["id"] + resp = client.put(f"/todos/{todo_id}", json={"completed": True}) + assert resp.status_code == 200 + assert resp.json()["completed"] is True + + def test_update_nonexistent(self) -> None: + """PUT on an unknown id should return 404.""" + resp = client.put("/todos/999", json={"title": "Nope"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /todos/{todo_id} +# --------------------------------------------------------------------------- + + +class TestDeleteTodo: + """Tests for deleting a todo.""" + + def test_delete_existing(self) -> None: + """DELETE on a valid id should succeed.""" + create_resp = client.post("/todos", json={"title": "Delete me"}) + todo_id = create_resp.json()["id"] + resp = client.delete(f"/todos/{todo_id}") + assert resp.status_code == 200 + assert resp.json()["detail"] == "Todo deleted successfully" + # Confirm it's gone + get_resp = client.get(f"/todos/{todo_id}") + assert get_resp.status_code == 404 + + def test_delete_nonexistent(self) -> None: + """DELETE on an unknown id should return 404.""" + resp = client.delete("/todos/999") + assert resp.status_code == 404 diff --git a/tests/test_main.py b/tests/test_main.py index 7393107..cb6a99d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,226 +1,231 @@ -"""Unit and integration tests for the Todo FastAPI application. +"""Tests for the main FastAPI application entry point. -Uses FastAPI's TestClient to exercise all CRUD endpoints and verify -correct status codes, response bodies, and 404 handling. The -in-memory store is reset before every test via a pytest fixture. +Covers: +- The root endpoint returns the expected welcome message. +- The todo router is correctly mounted (basic smoke tests). +- CRUD lifecycle through the API. """ from __future__ import annotations -import sys -from pathlib import Path - import pytest from fastapi.testclient import TestClient -# Ensure the project root is on sys.path so that 'main', 'routes', -# 'storage', and 'models' can be imported. -_PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) -if _PROJECT_ROOT not in sys.path: - sys.path.insert(0, _PROJECT_ROOT) - -from main import app # noqa: E402 -from routes import store # noqa: E402 +from main import app +from routes import store @pytest.fixture(autouse=True) def _reset_store() -> None: - """Reset the in-memory todo store before each test.""" + """Reset the in-memory store before every test.""" store.reset() -client = TestClient(app) +@pytest.fixture() +def client() -> TestClient: + """Return a TestClient bound to the FastAPI app.""" + return TestClient(app) # ------------------------------------------------------------------ -# Helper +# Root endpoint # ------------------------------------------------------------------ -def _create_todo( - title: str = "Test todo", - description: str | None = None, - completed: bool = False, -) -> dict: - """Post a new todo and return the parsed JSON response.""" - payload: dict = {"title": title} - if description is not None: - payload["description"] = description - if completed: - payload["completed"] = completed - response = client.post("/todos", json=payload) - return response.json() +class TestRootEndpoint: + """Tests for GET /.""" -# ------------------------------------------------------------------ -# POST /todos -# ------------------------------------------------------------------ + def test_root_returns_200(self, client: TestClient) -> None: + """GET / should return 200.""" + response = client.get("/") + assert response.status_code == 200 + + def test_root_message(self, client: TestClient) -> None: + """GET / should return the expected JSON message.""" + response = client.get("/") + assert response.json() == {"message": "Todo API is running"} -def test_create_todo_minimal() -> None: - """Creating a todo with only a title should succeed with 201.""" - response = client.post("/todos", json={"title": "Buy milk"}) - assert response.status_code == 201 - body = response.json() - assert body["title"] == "Buy milk" - assert body["completed"] is False - assert body["description"] is None - assert "id" in body - assert "created_at" in body +# ------------------------------------------------------------------ +# Create todo +# ------------------------------------------------------------------ -def test_create_todo_with_description() -> None: - """Creating a todo with a title and description should succeed.""" - response = client.post( - "/todos", - json={"title": "Read book", "description": "Chapter 5"}, - ) - assert response.status_code == 201 - body = response.json() - assert body["title"] == "Read book" - assert body["description"] == "Chapter 5" - assert body["completed"] is False +class TestCreateTodo: + """Tests for POST /todos.""" + def test_create_returns_201(self, client: TestClient) -> None: + """POST /todos should return 201 on success.""" + response = client.post("/todos", json={"title": "Buy milk"}) + assert response.status_code == 201 -def test_create_todo_with_completed_flag() -> None: - """Creating a todo that is already completed should honour the flag.""" - response = client.post( - "/todos", - json={"title": "Done task", "completed": True}, - ) - assert response.status_code == 201 - assert response.json()["completed"] is True + def test_create_returns_todo(self, client: TestClient) -> None: + """POST /todos should return the created todo with an id.""" + response = client.post( + "/todos", json={"title": "Walk the dog", "description": "In the park"} + ) + data = response.json() + assert data["id"] == 1 + assert data["title"] == "Walk the dog" + assert data["description"] == "In the park" + assert data["completed"] is False + assert "created_at" in data + def test_create_without_title_returns_422(self, client: TestClient) -> None: + """POST /todos with missing title should return 422.""" + response = client.post("/todos", json={}) + assert response.status_code == 422 -def test_create_todo_empty_title_rejected() -> None: - """An empty title should be rejected (422 validation error).""" - response = client.post("/todos", json={"title": ""}) - assert response.status_code == 422 + def test_create_with_empty_title_returns_422(self, client: TestClient) -> None: + """POST /todos with an empty title string should return 422.""" + response = client.post("/todos", json={"title": ""}) + assert response.status_code == 422 # ------------------------------------------------------------------ -# GET /todos +# List todos # ------------------------------------------------------------------ -def test_list_todos_empty() -> None: - """Listing todos when the store is empty should return an empty list.""" - response = client.get("/todos") - assert response.status_code == 200 - assert response.json() == [] +class TestListTodos: + """Tests for GET /todos.""" + def test_list_empty(self, client: TestClient) -> None: + """GET /todos should return an empty list when no todos exist.""" + response = client.get("/todos") + assert response.status_code == 200 + assert response.json() == [] -def test_list_todos_multiple() -> None: - """Listing todos should return all created items.""" - _create_todo(title="First") - _create_todo(title="Second") - response = client.get("/todos") - assert response.status_code == 200 - body = response.json() - assert len(body) == 2 - titles = {t["title"] for t in body} - assert titles == {"First", "Second"} + def test_list_after_create(self, client: TestClient) -> None: + """GET /todos should include todos that were previously created.""" + client.post("/todos", json={"title": "First"}) + client.post("/todos", json={"title": "Second"}) + response = client.get("/todos") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "First" + assert data[1]["title"] == "Second" # ------------------------------------------------------------------ -# GET /todos/{id} +# Get single todo # ------------------------------------------------------------------ -def test_get_single_todo() -> None: - """Retrieving a todo by ID should return the correct item.""" - created = _create_todo(title="Specific todo", description="details") - todo_id = created["id"] - response = client.get(f"/todos/{todo_id}") - assert response.status_code == 200 - body = response.json() - assert body["id"] == todo_id - assert body["title"] == "Specific todo" - assert body["description"] == "details" +class TestGetTodo: + """Tests for GET /todos/{todo_id}.""" + def test_get_existing(self, client: TestClient) -> None: + """GET /todos/{id} should return the todo when it exists.""" + client.post("/todos", json={"title": "Test"}) + response = client.get("/todos/1") + assert response.status_code == 200 + assert response.json()["title"] == "Test" -def test_get_todo_not_found() -> None: - """Requesting a non-existent todo should return 404.""" - response = client.get("/todos/9999") - assert response.status_code == 404 - assert response.json()["detail"] == "Todo not found" + def test_get_not_found(self, client: TestClient) -> None: + """GET /todos/{id} should return 404 for a non-existent id.""" + response = client.get("/todos/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" # ------------------------------------------------------------------ -# PUT /todos/{id} +# Update todo # ------------------------------------------------------------------ -def test_update_todo_title() -> None: - """Updating only the title should leave other fields unchanged.""" - created = _create_todo(title="Old title") - todo_id = created["id"] - response = client.put(f"/todos/{todo_id}", json={"title": "New title"}) - assert response.status_code == 200 - body = response.json() - assert body["title"] == "New title" - assert body["completed"] is False # unchanged - +class TestUpdateTodo: + """Tests for PUT /todos/{todo_id}.""" -def test_update_todo_completed() -> None: - """Updating the completed flag should be reflected in the response.""" - created = _create_todo(title="Task") - todo_id = created["id"] - response = client.put(f"/todos/{todo_id}", json={"completed": True}) - assert response.status_code == 200 - assert response.json()["completed"] is True + def test_update_title(self, client: TestClient) -> None: + """PUT /todos/{id} should update the title.""" + client.post("/todos", json={"title": "Old title"}) + response = client.put("/todos/1", json={"title": "New title"}) + assert response.status_code == 200 + assert response.json()["title"] == "New title" + def test_update_completed(self, client: TestClient) -> None: + """PUT /todos/{id} should update the completed flag.""" + client.post("/todos", json={"title": "Task"}) + response = client.put("/todos/1", json={"completed": True}) + assert response.status_code == 200 + assert response.json()["completed"] is True -def test_update_todo_description() -> None: - """Updating the description should work.""" - created = _create_todo(title="Task") - todo_id = created["id"] - response = client.put( - f"/todos/{todo_id}", json={"description": "new desc"} - ) - assert response.status_code == 200 - assert response.json()["description"] == "new desc" + def test_update_description(self, client: TestClient) -> None: + """PUT /todos/{id} should update the description.""" + client.post("/todos", json={"title": "Task"}) + response = client.put("/todos/1", json={"description": "Details"}) + assert response.status_code == 200 + assert response.json()["description"] == "Details" - -def test_update_todo_not_found() -> None: - """Updating a non-existent todo should return 404.""" - response = client.put("/todos/9999", json={"title": "Nope"}) - assert response.status_code == 404 - assert response.json()["detail"] == "Todo not found" + def test_update_not_found(self, client: TestClient) -> None: + """PUT /todos/{id} should return 404 for a non-existent id.""" + response = client.put("/todos/999", json={"title": "Nope"}) + assert response.status_code == 404 # ------------------------------------------------------------------ -# DELETE /todos/{id} +# Delete todo # ------------------------------------------------------------------ -def test_delete_todo() -> None: - """Deleting an existing todo should succeed and remove it.""" - created = _create_todo(title="To be deleted") - todo_id = created["id"] - - response = client.delete(f"/todos/{todo_id}") - assert response.status_code == 200 - assert response.json()["detail"] == "Todo deleted successfully" +class TestDeleteTodo: + """Tests for DELETE /todos/{todo_id}.""" - # Confirm it's gone - get_response = client.get(f"/todos/{todo_id}") - assert get_response.status_code == 404 + def test_delete_existing(self, client: TestClient) -> None: + """DELETE /todos/{id} should return 204 when the todo exists.""" + client.post("/todos", json={"title": "To remove"}) + response = client.delete("/todos/1") + assert response.status_code == 204 + def test_delete_removes_todo(self, client: TestClient) -> None: + """After DELETE, GET should return 404 for the same id.""" + client.post("/todos", json={"title": "To remove"}) + client.delete("/todos/1") + response = client.get("/todos/1") + assert response.status_code == 404 -def test_delete_todo_not_found() -> None: - """Deleting a non-existent todo should return 404.""" - response = client.delete("/todos/9999") - assert response.status_code == 404 - assert response.json()["detail"] == "Todo not found" + def test_delete_not_found(self, client: TestClient) -> None: + """DELETE /todos/{id} should return 404 for a non-existent id.""" + response = client.delete("/todos/999") + assert response.status_code == 404 # ------------------------------------------------------------------ -# Store isolation +# Full lifecycle # ------------------------------------------------------------------ -def test_store_reset_between_tests() -> None: - """Verify the store is empty at the start of each test (fixture works).""" - response = client.get("/todos") - assert response.status_code == 200 - assert response.json() == [] +class TestLifecycle: + """End-to-end CRUD lifecycle test.""" + + def test_full_crud_cycle(self, client: TestClient) -> None: + """Create, read, update, and delete a todo through the API.""" + # Create + create_resp = client.post( + "/todos", json={"title": "Lifecycle", "description": "Full test"} + ) + assert create_resp.status_code == 201 + todo_id = create_resp.json()["id"] + + # Read + get_resp = client.get(f"/todos/{todo_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["title"] == "Lifecycle" + + # Update + put_resp = client.put( + f"/todos/{todo_id}", json={"completed": True, "title": "Done"} + ) + assert put_resp.status_code == 200 + assert put_resp.json()["completed"] is True + assert put_resp.json()["title"] == "Done" + + # Delete + del_resp = client.delete(f"/todos/{todo_id}") + assert del_resp.status_code == 204 + + # Verify gone + gone_resp = client.get(f"/todos/{todo_id}") + assert gone_resp.status_code == 404 diff --git a/tests/test_models.py b/tests/test_models.py index e2706b1..975f4e5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,4 @@ -"""Tests for Pydantic models defined in models.py.""" +"""Unit tests for Pydantic models in models.py.""" from __future__ import annotations @@ -8,73 +8,103 @@ from models import TodoCreate, TodoResponse, TodoUpdate +# --------------------------------------------------------------------------- +# TodoCreate +# --------------------------------------------------------------------------- + + class TestTodoCreate: - """Tests for the TodoCreate model.""" + """Tests for the TodoCreate schema.""" - def test_create_with_title_only(self) -> None: - """Title is the only required field.""" + def test_minimal_creation(self) -> None: + """Only title is required; defaults should be applied.""" todo = TodoCreate(title="Buy milk") assert todo.title == "Buy milk" assert todo.description is None assert todo.completed is False - def test_create_with_all_fields(self) -> None: - """All fields can be provided explicitly.""" - todo = TodoCreate(title="Buy milk", description="2% milk", completed=True) - assert todo.title == "Buy milk" - assert todo.description == "2% milk" + def test_full_creation(self) -> None: + """All fields can be supplied explicitly.""" + todo = TodoCreate( + title="Deploy app", + description="Push to production", + completed=True, + ) + assert todo.title == "Deploy app" + assert todo.description == "Push to production" assert todo.completed is True - def test_create_missing_title_raises(self) -> None: - """Omitting title must raise a validation error.""" + def test_empty_title_rejected(self) -> None: + """An empty title string must be rejected by the min_length constraint.""" with pytest.raises(ValidationError): - TodoCreate() # type: ignore[call-arg] + TodoCreate(title="") - def test_create_empty_title_raises(self) -> None: - """An empty string title must raise a validation error.""" + def test_missing_title_rejected(self) -> None: + """Title is required — omitting it must raise a validation error.""" with pytest.raises(ValidationError): - TodoCreate(title="") + TodoCreate() # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# TodoUpdate +# --------------------------------------------------------------------------- class TestTodoUpdate: - """Tests for the TodoUpdate model.""" + """Tests for the TodoUpdate schema.""" - def test_update_all_none_by_default(self) -> None: - """All fields default to None.""" + def test_all_fields_optional(self) -> None: + """Creating an update with no fields should succeed.""" update = TodoUpdate() assert update.title is None assert update.description is None assert update.completed is None - def test_update_partial(self) -> None: - """Only supplied fields are set.""" + def test_partial_update(self) -> None: + """Only some fields can be set.""" update = TodoUpdate(completed=True) assert update.completed is True assert update.title is None + def test_empty_title_rejected(self) -> None: + """An empty title string must be rejected.""" + with pytest.raises(ValidationError): + TodoUpdate(title="") + + +# --------------------------------------------------------------------------- +# TodoResponse +# --------------------------------------------------------------------------- + class TestTodoResponse: - """Tests for the TodoResponse model.""" - - def test_response_roundtrip(self) -> None: - """All fields are populated correctly.""" - data = { - "id": 1, - "title": "Test", - "description": "A test todo", - "completed": False, - "created_at": "2024-01-01T00:00:00+00:00", - } - resp = TodoResponse(**data) + """Tests for the TodoResponse schema.""" + + def test_full_response(self) -> None: + """All fields present should produce a valid response model.""" + resp = TodoResponse( + id=1, + title="Test", + description="A description", + completed=False, + created_at="2024-01-01T00:00:00+00:00", + ) assert resp.id == 1 assert resp.title == "Test" - assert resp.description == "A test todo" + assert resp.description == "A description" assert resp.completed is False - assert resp.created_at == "2024-01-01T00:00:00+00:00" - def test_response_description_defaults_to_none(self) -> None: - """Description is optional and defaults to None.""" + def test_description_defaults_to_none(self) -> None: + """Description should default to None if omitted.""" resp = TodoResponse( - id=2, title="No desc", completed=False, created_at="2024-01-01T00:00:00" + id=2, + title="No desc", + completed=True, + created_at="2024-01-01T00:00:00+00:00", ) assert resp.description is None + + def test_missing_required_field(self) -> None: + """Omitting a required field must raise a validation error.""" + with pytest.raises(ValidationError): + TodoResponse(title="Missing id", completed=False, created_at="now") # type: ignore[call-arg] diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..ff91a8a --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,304 @@ +"""Comprehensive tests for the Todo CRUD API routes. + +Uses the FastAPI TestClient (backed by httpx) to exercise every endpoint, +including happy paths and error cases (404s, validation errors). +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from main import app +from routes import store + + +@pytest.fixture(autouse=True) +def _reset_store() -> None: + """Reset the in-memory store before every test.""" + store.reset() + + +@pytest.fixture() +def client() -> TestClient: + """Return a TestClient bound to the FastAPI application.""" + return TestClient(app) + + +# ── Root endpoint ───────────────────────────────────────────────────────── + + +class TestRoot: + """Tests for GET /.""" + + def test_root_returns_welcome_message(self, client: TestClient) -> None: + """GET / should return the welcome JSON.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Todo API is running"} + + +# ── POST /todos ─────────────────────────────────────────────────────────── + + +class TestCreateTodo: + """Tests for POST /todos.""" + + def test_create_todo_minimal(self, client: TestClient) -> None: + """Create a todo with only a title.""" + response = client.post("/todos", json={"title": "Buy milk"}) + assert response.status_code == 201 + body = response.json() + assert body["id"] == 1 + assert body["title"] == "Buy milk" + assert body["description"] is None + assert body["completed"] is False + assert "created_at" in body + + def test_create_todo_with_all_fields(self, client: TestClient) -> None: + """Create a todo supplying every field.""" + payload = { + "title": "Learn FastAPI", + "description": "Work through the tutorial", + "completed": True, + } + response = client.post("/todos", json=payload) + assert response.status_code == 201 + body = response.json() + assert body["title"] == "Learn FastAPI" + assert body["description"] == "Work through the tutorial" + assert body["completed"] is True + + def test_create_todo_auto_increments_id(self, client: TestClient) -> None: + """IDs should increase monotonically.""" + r1 = client.post("/todos", json={"title": "First"}) + r2 = client.post("/todos", json={"title": "Second"}) + assert r1.json()["id"] == 1 + assert r2.json()["id"] == 2 + + def test_create_todo_empty_title_returns_422(self, client: TestClient) -> None: + """An empty title should be rejected by Pydantic validation.""" + response = client.post("/todos", json={"title": ""}) + assert response.status_code == 422 + + def test_create_todo_missing_title_returns_422(self, client: TestClient) -> None: + """A missing title should be rejected.""" + response = client.post("/todos", json={}) + assert response.status_code == 422 + + +# ── GET /todos ──────────────────────────────────────────────────────────── + + +class TestListTodos: + """Tests for GET /todos.""" + + def test_list_empty(self, client: TestClient) -> None: + """An empty store should return an empty list.""" + response = client.get("/todos") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_multiple(self, client: TestClient) -> None: + """All created todos should appear in the list.""" + client.post("/todos", json={"title": "A"}) + client.post("/todos", json={"title": "B"}) + client.post("/todos", json={"title": "C"}) + + response = client.get("/todos") + assert response.status_code == 200 + data = response.json() + assert len(data) == 3 + titles = {t["title"] for t in data} + assert titles == {"A", "B", "C"} + + +# ── GET /todos/{todo_id} ────────────────────────────────────────────────── + + +class TestGetTodo: + """Tests for GET /todos/{todo_id}.""" + + def test_get_existing(self, client: TestClient) -> None: + """Retrieve a todo that exists.""" + create_resp = client.post("/todos", json={"title": "Test"}) + todo_id = create_resp.json()["id"] + + response = client.get(f"/todos/{todo_id}") + assert response.status_code == 200 + assert response.json()["id"] == todo_id + assert response.json()["title"] == "Test" + + def test_get_nonexistent_returns_404(self, client: TestClient) -> None: + """A missing todo should yield 404.""" + response = client.get("/todos/9999") + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" + + +# ── PUT /todos/{todo_id} ────────────────────────────────────────────────── + + +class TestUpdateTodo: + """Tests for PUT /todos/{todo_id}.""" + + def test_update_title(self, client: TestClient) -> None: + """Updating only the title should leave other fields unchanged.""" + create_resp = client.post("/todos", json={"title": "Old"}) + todo_id = create_resp.json()["id"] + + response = client.put(f"/todos/{todo_id}", json={"title": "New"}) + assert response.status_code == 200 + body = response.json() + assert body["title"] == "New" + assert body["completed"] is False # unchanged + + def test_update_completed(self, client: TestClient) -> None: + """Toggle the completed flag.""" + create_resp = client.post("/todos", json={"title": "Do it"}) + todo_id = create_resp.json()["id"] + + response = client.put(f"/todos/{todo_id}", json={"completed": True}) + assert response.status_code == 200 + assert response.json()["completed"] is True + + def test_update_description(self, client: TestClient) -> None: + """Set the description on an existing todo.""" + create_resp = client.post("/todos", json={"title": "Stuff"}) + todo_id = create_resp.json()["id"] + + response = client.put( + f"/todos/{todo_id}", json={"description": "More details"} + ) + assert response.status_code == 200 + assert response.json()["description"] == "More details" + + def test_update_multiple_fields(self, client: TestClient) -> None: + """Update several fields at once.""" + create_resp = client.post("/todos", json={"title": "Original"}) + todo_id = create_resp.json()["id"] + + payload = {"title": "Updated", "description": "Desc", "completed": True} + response = client.put(f"/todos/{todo_id}", json=payload) + assert response.status_code == 200 + body = response.json() + assert body["title"] == "Updated" + assert body["description"] == "Desc" + assert body["completed"] is True + + def test_update_nonexistent_returns_404(self, client: TestClient) -> None: + """Updating a missing todo should yield 404.""" + response = client.put("/todos/9999", json={"title": "Nope"}) + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" + + def test_update_no_fields_keeps_original(self, client: TestClient) -> None: + """Sending an empty update body should leave the todo unchanged.""" + create_resp = client.post( + "/todos", + json={"title": "Keep", "description": "Same", "completed": False}, + ) + todo_id = create_resp.json()["id"] + + response = client.put(f"/todos/{todo_id}", json={}) + assert response.status_code == 200 + body = response.json() + assert body["title"] == "Keep" + assert body["description"] == "Same" + assert body["completed"] is False + + +# ── DELETE /todos/{todo_id} ─────────────────────────────────────────────── + + +class TestDeleteTodo: + """Tests for DELETE /todos/{todo_id}.""" + + def test_delete_existing(self, client: TestClient) -> None: + """Deleting an existing todo should return 204 with no body.""" + create_resp = client.post("/todos", json={"title": "Remove me"}) + todo_id = create_resp.json()["id"] + + response = client.delete(f"/todos/{todo_id}") + assert response.status_code == 204 + assert response.content == b"" + + # Confirm it's actually gone + get_resp = client.get(f"/todos/{todo_id}") + assert get_resp.status_code == 404 + + def test_delete_nonexistent_returns_404(self, client: TestClient) -> None: + """Deleting a missing todo should yield 404.""" + response = client.delete("/todos/9999") + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" + + def test_delete_removes_from_list(self, client: TestClient) -> None: + """After deletion, the todo should no longer appear in the list.""" + client.post("/todos", json={"title": "Stay"}) + create_resp = client.post("/todos", json={"title": "Go away"}) + todo_id = create_resp.json()["id"] + + client.delete(f"/todos/{todo_id}") + + list_resp = client.get("/todos") + ids = [t["id"] for t in list_resp.json()] + assert todo_id not in ids + assert len(list_resp.json()) == 1 + + +# ── Integration / edge-case tests ───────────────────────────────────────── + + +class TestIntegration: + """End-to-end integration and edge-case tests.""" + + def test_full_lifecycle(self, client: TestClient) -> None: + """Create → read → update → read → delete → confirm gone.""" + # Create + r = client.post("/todos", json={"title": "Lifecycle"}) + assert r.status_code == 201 + todo_id = r.json()["id"] + + # Read + r = client.get(f"/todos/{todo_id}") + assert r.status_code == 200 + assert r.json()["title"] == "Lifecycle" + + # Update + r = client.put(f"/todos/{todo_id}", json={"completed": True}) + assert r.status_code == 200 + assert r.json()["completed"] is True + + # Read again to confirm persistence + r = client.get(f"/todos/{todo_id}") + assert r.json()["completed"] is True + + # Delete + r = client.delete(f"/todos/{todo_id}") + assert r.status_code == 204 + + # Confirm gone + r = client.get(f"/todos/{todo_id}") + assert r.status_code == 404 + + def test_create_after_delete_uses_next_id(self, client: TestClient) -> None: + """IDs should never be reused — counter continues after deletion.""" + r1 = client.post("/todos", json={"title": "First"}) + first_id = r1.json()["id"] + + client.delete(f"/todos/{first_id}") + + r2 = client.post("/todos", json={"title": "Second"}) + assert r2.json()["id"] == first_id + 1 + + def test_double_delete_returns_404(self, client: TestClient) -> None: + """Deleting the same todo twice should yield 404 on the second call.""" + r = client.post("/todos", json={"title": "Twice"}) + todo_id = r.json()["id"] + + first_delete = client.delete(f"/todos/{todo_id}") + assert first_delete.status_code == 204 + + second_delete = client.delete(f"/todos/{todo_id}") + assert second_delete.status_code == 404 diff --git a/tests/test_storage.py b/tests/test_storage.py index 6a969e6..049b272 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,4 +1,4 @@ -"""Tests for the in-memory TodoStore in storage.py.""" +"""Unit tests for the in-memory TodoStore.""" from __future__ import annotations @@ -9,125 +9,126 @@ @pytest.fixture() def store() -> TodoStore: - """Provide a fresh TodoStore for each test.""" + """Return a fresh TodoStore instance.""" return TodoStore() class TestAdd: """Tests for TodoStore.add.""" - def test_add_returns_todo_with_id(self, store: TodoStore) -> None: - """Adding a todo returns a dict with an auto-incremented ID.""" - todo = store.add(title="First") - assert todo["id"] == 1 - assert todo["title"] == "First" - assert todo["description"] is None - assert todo["completed"] is False - assert "created_at" in todo + def test_add_returns_dict(self, store: TodoStore) -> None: + """add() should return a dictionary.""" + result = store.add(title="Test") + assert isinstance(result, dict) + + def test_add_assigns_id(self, store: TodoStore) -> None: + """add() should assign an auto-incrementing id.""" + first = store.add(title="First") + second = store.add(title="Second") + assert first["id"] == 1 + assert second["id"] == 2 + + def test_add_stores_fields(self, store: TodoStore) -> None: + """add() should store title, description, completed, and created_at.""" + result = store.add(title="T", description="D", completed=True) + assert result["title"] == "T" + assert result["description"] == "D" + assert result["completed"] is True + assert "created_at" in result + + def test_add_defaults(self, store: TodoStore) -> None: + """add() should default description to None and completed to False.""" + result = store.add(title="X") + assert result["description"] is None + assert result["completed"] is False - def test_add_increments_id(self, store: TodoStore) -> None: - """Each new todo gets a unique, incrementing ID.""" - t1 = store.add(title="A") - t2 = store.add(title="B") - assert t2["id"] == t1["id"] + 1 - def test_add_with_description_and_completed(self, store: TodoStore) -> None: - """Optional fields are stored when provided.""" - todo = store.add(title="X", description="detail", completed=True) - assert todo["description"] == "detail" - assert todo["completed"] is True +class TestGetAll: + """Tests for TodoStore.get_all.""" + + def test_empty_store(self, store: TodoStore) -> None: + """get_all() should return an empty list on a fresh store.""" + assert store.get_all() == [] + + def test_returns_all(self, store: TodoStore) -> None: + """get_all() should return every added todo.""" + store.add(title="A") + store.add(title="B") + assert len(store.get_all()) == 2 class TestGet: """Tests for TodoStore.get.""" def test_get_existing(self, store: TodoStore) -> None: - """Retrieving an existing todo returns its data.""" - created = store.add(title="Find me") - found = store.get(created["id"]) + """get() should return the todo when it exists.""" + added = store.add(title="Find me") + found = store.get(added["id"]) assert found is not None assert found["title"] == "Find me" - def test_get_nonexistent_returns_none(self, store: TodoStore) -> None: - """Retrieving a missing ID returns None.""" - assert store.get(999) is None - - def test_get_returns_copy(self, store: TodoStore) -> None: - """Returned dict is a copy — mutating it doesn't affect the store.""" - created = store.add(title="Original") - fetched = store.get(created["id"]) - assert fetched is not None - fetched["title"] = "Mutated" - assert store.get(created["id"])["title"] == "Original" # type: ignore[index] - - -class TestGetAll: - """Tests for TodoStore.get_all.""" - - def test_get_all_empty(self, store: TodoStore) -> None: - """An empty store returns an empty list.""" - assert store.get_all() == [] - - def test_get_all_returns_all(self, store: TodoStore) -> None: - """All added items appear in get_all results.""" - store.add(title="A") - store.add(title="B") - all_todos = store.get_all() - assert len(all_todos) == 2 + def test_get_missing(self, store: TodoStore) -> None: + """get() should return None for a non-existent id.""" + assert store.get(42) is None class TestUpdate: """Tests for TodoStore.update.""" def test_update_title(self, store: TodoStore) -> None: - """Updating only the title leaves other fields unchanged.""" - created = store.add(title="Old") - updated = store.update(created["id"], title="New") + """update() should change the title.""" + added = store.add(title="Old") + updated = store.update(added["id"], title="New") assert updated is not None assert updated["title"] == "New" - assert updated["completed"] is False def test_update_completed(self, store: TodoStore) -> None: - """Updating the completed flag works.""" - created = store.add(title="Task") - updated = store.update(created["id"], completed=True) + """update() should change the completed flag.""" + added = store.add(title="Task") + updated = store.update(added["id"], completed=True) assert updated is not None assert updated["completed"] is True - def test_update_nonexistent_returns_none(self, store: TodoStore) -> None: - """Updating a missing ID returns None.""" - assert store.update(999, title="Nope") is None + def test_update_missing(self, store: TodoStore) -> None: + """update() should return None for a non-existent id.""" + assert store.update(99, title="X") is None - def test_update_no_fields_is_noop(self, store: TodoStore) -> None: - """Calling update with no optional fields returns the item unchanged.""" - created = store.add(title="Same") - updated = store.update(created["id"]) + def test_partial_update_leaves_other_fields(self, store: TodoStore) -> None: + """update() should only change fields that are provided.""" + added = store.add(title="Keep", description="Me") + updated = store.update(added["id"], completed=True) assert updated is not None - assert updated["title"] == "Same" + assert updated["title"] == "Keep" + assert updated["description"] == "Me" class TestDelete: """Tests for TodoStore.delete.""" def test_delete_existing(self, store: TodoStore) -> None: - """Deleting an existing todo returns True and removes it.""" - created = store.add(title="Bye") - assert store.delete(created["id"]) is True - assert store.get(created["id"]) is None + """delete() should return True and remove the todo.""" + added = store.add(title="Bye") + assert store.delete(added["id"]) is True + assert store.get(added["id"]) is None - def test_delete_nonexistent_returns_false(self, store: TodoStore) -> None: - """Deleting a missing ID returns False.""" - assert store.delete(999) is False + def test_delete_missing(self, store: TodoStore) -> None: + """delete() should return False for a non-existent id.""" + assert store.delete(99) is False class TestReset: """Tests for TodoStore.reset.""" - def test_reset_clears_all(self, store: TodoStore) -> None: - """Reset empties the store and resets the ID counter.""" + def test_reset_clears_store(self, store: TodoStore) -> None: + """reset() should remove all todos.""" store.add(title="A") store.add(title="B") store.reset() assert store.get_all() == [] - new = store.add(title="C") + + def test_reset_resets_counter(self, store: TodoStore) -> None: + """reset() should reset the ID counter so next add starts at 1.""" + store.add(title="A") + store.reset() + new = store.add(title="B") assert new["id"] == 1 diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..1d832ff --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,158 @@ +"""Unit tests for the in-memory store modules (storage.py and store.py).""" + +from __future__ import annotations + +import store as functional_store +from storage import TodoStore + + +# --------------------------------------------------------------------------- +# TodoStore (class-based, storage.py) +# --------------------------------------------------------------------------- + + +class TestTodoStore: + """Tests for the class-based TodoStore.""" + + def setup_method(self) -> None: + """Create a fresh store before every test.""" + self.store = TodoStore() + + def test_add_returns_complete_todo(self) -> None: + """add() should return a dict with all expected keys.""" + todo = self.store.add(title="First") + assert todo["id"] == 1 + assert todo["title"] == "First" + assert todo["description"] is None + assert todo["completed"] is False + assert "created_at" in todo + + def test_auto_increment_ids(self) -> None: + """Each call to add() should produce a monotonically increasing id.""" + t1 = self.store.add(title="A") + t2 = self.store.add(title="B") + assert t2["id"] == t1["id"] + 1 + + def test_get_existing(self) -> None: + """get() should return the correct todo for a valid id.""" + created = self.store.add(title="Find me") + fetched = self.store.get(created["id"]) + assert fetched is not None + assert fetched["title"] == "Find me" + + def test_get_nonexistent(self) -> None: + """get() should return None for an unknown id.""" + assert self.store.get(999) is None + + def test_get_all_empty(self) -> None: + """get_all() on an empty store should return an empty list.""" + assert self.store.get_all() == [] + + def test_get_all_multiple(self) -> None: + """get_all() should return all added todos.""" + self.store.add(title="A") + self.store.add(title="B") + assert len(self.store.get_all()) == 2 + + def test_update_partial(self) -> None: + """update() should only change supplied fields.""" + created = self.store.add(title="Original", description="desc") + updated = self.store.update(created["id"], completed=True) + assert updated is not None + assert updated["title"] == "Original" + assert updated["description"] == "desc" + assert updated["completed"] is True + + def test_update_nonexistent(self) -> None: + """update() on a missing id should return None.""" + assert self.store.update(999, title="Nope") is None + + def test_delete_existing(self) -> None: + """delete() should return True and remove the todo.""" + created = self.store.add(title="Delete me") + assert self.store.delete(created["id"]) is True + assert self.store.get(created["id"]) is None + + def test_delete_nonexistent(self) -> None: + """delete() on a missing id should return False.""" + assert self.store.delete(999) is False + + def test_reset(self) -> None: + """reset() should clear all todos and reset the counter.""" + self.store.add(title="A") + self.store.add(title="B") + self.store.reset() + assert self.store.get_all() == [] + new_todo = self.store.add(title="C") + assert new_todo["id"] == 1 + + +# --------------------------------------------------------------------------- +# Functional store (store.py) +# --------------------------------------------------------------------------- + + +class TestFunctionalStore: + """Tests for the module-level functional store.""" + + def setup_method(self) -> None: + """Reset module-level state before every test.""" + functional_store.reset_store() + + def test_create_todo(self) -> None: + """create_todo() should return a complete todo dict.""" + todo = functional_store.create_todo({"title": "Test"}) + assert todo["id"] == 1 + assert todo["title"] == "Test" + assert todo["completed"] is False + assert todo["description"] is None + assert "created_at" in todo + + def test_get_all_todos(self) -> None: + """get_all_todos() should return all created todos.""" + functional_store.create_todo({"title": "A"}) + functional_store.create_todo({"title": "B"}) + assert len(functional_store.get_all_todos()) == 2 + + def test_get_todo_by_id(self) -> None: + """get_todo_by_id() should retrieve the correct todo.""" + created = functional_store.create_todo({"title": "Find"}) + fetched = functional_store.get_todo_by_id(created["id"]) + assert fetched is not None + assert fetched["title"] == "Find" + + def test_get_todo_by_id_missing(self) -> None: + """get_todo_by_id() should return None for unknown id.""" + assert functional_store.get_todo_by_id(999) is None + + def test_update_todo(self) -> None: + """update_todo() should partially update the todo.""" + created = functional_store.create_todo({"title": "Old"}) + updated = functional_store.update_todo( + created["id"], {"title": "New", "completed": True} + ) + assert updated is not None + assert updated["title"] == "New" + assert updated["completed"] is True + + def test_update_todo_missing(self) -> None: + """update_todo() should return None for unknown id.""" + assert functional_store.update_todo(999, {"title": "X"}) is None + + def test_delete_todo(self) -> None: + """delete_todo() should remove the todo and return True.""" + created = functional_store.create_todo({"title": "Delete"}) + assert functional_store.delete_todo(created["id"]) is True + assert functional_store.get_todo_by_id(created["id"]) is None + + def test_delete_todo_missing(self) -> None: + """delete_todo() should return False for unknown id.""" + assert functional_store.delete_todo(999) is False + + def test_reset_store(self) -> None: + """reset_store() should clear all data and reset the counter.""" + functional_store.create_todo({"title": "X"}) + functional_store.reset_store() + assert functional_store.get_all_todos() == [] + new = functional_store.create_todo({"title": "Y"}) + assert new["id"] == 1 diff --git a/tests/test_todos.py b/tests/test_todos.py index df63118..d3d8657 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -1,184 +1,367 @@ -"""Tests for the Todo CRUD API endpoints. +"""Unit and integration tests for the Todo API. -Uses FastAPI's TestClient against the main app instance. -Each test resets the in-memory store to ensure isolation. +Covers all five CRUD endpoints: +- POST /todos — create a new todo +- GET /todos — list all todos +- GET /todos/{id} — retrieve a single todo +- PUT /todos/{id} — update an existing todo +- DELETE /todos/{id} — delete a todo + +Also verifies 404 responses for GET, PUT, and DELETE on non-existent IDs. +The in-memory store is reset between every test via a pytest fixture. """ from __future__ import annotations +import sys +from pathlib import Path +from typing import Any, Dict + import pytest from fastapi.testclient import TestClient -from main import app -from routes import store +# Ensure the project root is on sys.path so that imports resolve correctly. +_PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +from main import app # noqa: E402 +from routes import store # noqa: E402 @pytest.fixture(autouse=True) def _reset_store() -> None: - """Reset the in-memory store before every test.""" + """Reset the in-memory todo store before every test. + + This guarantees each test starts with a clean, empty store and + predictable auto-increment IDs. + """ store.reset() @pytest.fixture() def client() -> TestClient: - """Return a TestClient bound to the FastAPI app.""" + """Return a FastAPI TestClient wired to the application.""" return TestClient(app) -# ---- POST /todos ----------------------------------------------------------- - - -def test_create_todo(client: TestClient) -> None: - """POST /todos should create and return a new todo with status 201.""" - response = client.post("/todos", json={"title": "Buy milk"}) +# ------------------------------------------------------------------ +# Helper +# ------------------------------------------------------------------ + +def _create_todo( + client: TestClient, + title: str = "Test todo", + description: str | None = None, + completed: bool = False, +) -> Dict[str, Any]: + """Convenience helper that creates a todo and returns the JSON body.""" + payload: Dict[str, Any] = {"title": title, "completed": completed} + if description is not None: + payload["description"] = description + response = client.post("/todos", json=payload) assert response.status_code == 201 - data = response.json() - assert data["id"] == 1 - assert data["title"] == "Buy milk" - assert data["description"] is None - assert data["completed"] is False - assert "created_at" in data - - -def test_create_todo_with_description(client: TestClient) -> None: - """POST /todos with description should persist it.""" - response = client.post( - "/todos", json={"title": "Read book", "description": "Chapter 3"} - ) - assert response.status_code == 201 - assert response.json()["description"] == "Chapter 3" - - -def test_create_todo_with_completed(client: TestClient) -> None: - """POST /todos with completed=True should persist it.""" - response = client.post( - "/todos", json={"title": "Done task", "completed": True} - ) - assert response.status_code == 201 - assert response.json()["completed"] is True - - -def test_create_todo_missing_title(client: TestClient) -> None: - """POST /todos without title should return 422.""" - response = client.post("/todos", json={}) - assert response.status_code == 422 - - -def test_create_todo_empty_title(client: TestClient) -> None: - """POST /todos with empty title should return 422.""" - response = client.post("/todos", json={"title": ""}) - assert response.status_code == 422 - - -# ---- GET /todos ------------------------------------------------------------ - - -def test_list_todos_empty(client: TestClient) -> None: - """GET /todos on an empty store should return an empty list.""" - response = client.get("/todos") - assert response.status_code == 200 - assert response.json() == [] - - -def test_list_todos(client: TestClient) -> None: - """GET /todos should return all created todos.""" - client.post("/todos", json={"title": "First"}) - client.post("/todos", json={"title": "Second"}) - response = client.get("/todos") - assert response.status_code == 200 - data = response.json() - assert len(data) == 2 - assert data[0]["title"] == "First" - assert data[1]["title"] == "Second" - - -# ---- GET /todos/{id} ------------------------------------------------------- - - -def test_get_todo(client: TestClient) -> None: - """GET /todos/{id} should return the correct todo.""" - create_resp = client.post("/todos", json={"title": "Test"}) - todo_id = create_resp.json()["id"] - response = client.get(f"/todos/{todo_id}") - assert response.status_code == 200 - assert response.json()["title"] == "Test" - - -def test_get_todo_not_found(client: TestClient) -> None: - """GET /todos/{id} for a non-existent id should return 404.""" - response = client.get("/todos/999") - assert response.status_code == 404 - assert response.json()["detail"] == "Todo not found" - - -# ---- PUT /todos/{id} ------------------------------------------------------- - - -def test_update_todo_title(client: TestClient) -> None: - """PUT /todos/{id} should update the title.""" - create_resp = client.post("/todos", json={"title": "Old"}) - todo_id = create_resp.json()["id"] - response = client.put(f"/todos/{todo_id}", json={"title": "New"}) - assert response.status_code == 200 - assert response.json()["title"] == "New" - - -def test_update_todo_completed(client: TestClient) -> None: - """PUT /todos/{id} should update the completed status.""" - create_resp = client.post("/todos", json={"title": "Task"}) - todo_id = create_resp.json()["id"] - response = client.put(f"/todos/{todo_id}", json={"completed": True}) - assert response.status_code == 200 - assert response.json()["completed"] is True - - -def test_update_todo_description(client: TestClient) -> None: - """PUT /todos/{id} should update the description.""" - create_resp = client.post("/todos", json={"title": "Task"}) - todo_id = create_resp.json()["id"] - response = client.put( - f"/todos/{todo_id}", json={"description": "Details"} - ) - assert response.status_code == 200 - assert response.json()["description"] == "Details" - - -def test_update_todo_not_found(client: TestClient) -> None: - """PUT /todos/{id} for a non-existent id should return 404.""" - response = client.put("/todos/999", json={"title": "Nope"}) - assert response.status_code == 404 - assert response.json()["detail"] == "Todo not found" - - -# ---- DELETE /todos/{id} ---------------------------------------------------- - - -def test_delete_todo(client: TestClient) -> None: - """DELETE /todos/{id} should remove the todo and return success.""" - create_resp = client.post("/todos", json={"title": "Bye"}) - todo_id = create_resp.json()["id"] - response = client.delete(f"/todos/{todo_id}") - assert response.status_code == 200 - assert response.json()["detail"] == "Todo deleted successfully" - - # Confirm it is gone - get_resp = client.get(f"/todos/{todo_id}") - assert get_resp.status_code == 404 - - -def test_delete_todo_not_found(client: TestClient) -> None: - """DELETE /todos/{id} for a non-existent id should return 404.""" - response = client.delete("/todos/999") - assert response.status_code == 404 - assert response.json()["detail"] == "Todo not found" - - -# ---- ID auto-increment ----------------------------------------------------- - - -def test_auto_increment_ids(client: TestClient) -> None: - """Consecutive creates should yield incrementing IDs.""" - r1 = client.post("/todos", json={"title": "A"}) - r2 = client.post("/todos", json={"title": "B"}) - assert r1.json()["id"] == 1 - assert r2.json()["id"] == 2 + return response.json() + + +# ------------------------------------------------------------------ +# POST /todos +# ------------------------------------------------------------------ + +class TestCreateTodo: + """Tests for the POST /todos endpoint.""" + + def test_create_todo_minimal(self, client: TestClient) -> None: + """Creating a todo with only a title should succeed.""" + response = client.post("/todos", json={"title": "Buy milk"}) + assert response.status_code == 201 + body = response.json() + assert body["id"] == 1 + assert body["title"] == "Buy milk" + assert body["description"] is None + assert body["completed"] is False + assert "created_at" in body + + def test_create_todo_with_description(self, client: TestClient) -> None: + """Creating a todo with a description should persist it.""" + response = client.post( + "/todos", + json={"title": "Read book", "description": "Chapter 5"}, + ) + assert response.status_code == 201 + body = response.json() + assert body["title"] == "Read book" + assert body["description"] == "Chapter 5" + + def test_create_todo_with_completed_true(self, client: TestClient) -> None: + """Creating a todo that is already completed should be allowed.""" + response = client.post( + "/todos", + json={"title": "Done task", "completed": True}, + ) + assert response.status_code == 201 + assert response.json()["completed"] is True + + def test_create_todo_auto_increment_ids(self, client: TestClient) -> None: + """Successive creates should yield incrementing IDs.""" + first = _create_todo(client, title="First") + second = _create_todo(client, title="Second") + third = _create_todo(client, title="Third") + assert first["id"] == 1 + assert second["id"] == 2 + assert third["id"] == 3 + + def test_create_todo_missing_title(self, client: TestClient) -> None: + """Omitting the required 'title' field should return 422.""" + response = client.post("/todos", json={}) + assert response.status_code == 422 + + def test_create_todo_empty_title(self, client: TestClient) -> None: + """An empty title string should be rejected (min_length=1).""" + response = client.post("/todos", json={"title": ""}) + assert response.status_code == 422 + + +# ------------------------------------------------------------------ +# GET /todos +# ------------------------------------------------------------------ + +class TestListTodos: + """Tests for the GET /todos endpoint.""" + + def test_list_todos_empty(self, client: TestClient) -> None: + """When no todos exist, the list should be empty.""" + response = client.get("/todos") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_todos_multiple(self, client: TestClient) -> None: + """All created todos should appear in the listing.""" + _create_todo(client, title="Alpha") + _create_todo(client, title="Beta") + _create_todo(client, title="Gamma") + + response = client.get("/todos") + assert response.status_code == 200 + todos = response.json() + assert len(todos) == 3 + titles = {t["title"] for t in todos} + assert titles == {"Alpha", "Beta", "Gamma"} + + def test_list_todos_returns_correct_fields(self, client: TestClient) -> None: + """Each item in the list should contain all expected fields.""" + _create_todo(client, title="Check fields", description="desc") + response = client.get("/todos") + todo = response.json()[0] + assert "id" in todo + assert "title" in todo + assert "description" in todo + assert "completed" in todo + assert "created_at" in todo + + +# ------------------------------------------------------------------ +# GET /todos/{todo_id} +# ------------------------------------------------------------------ + +class TestGetTodo: + """Tests for the GET /todos/{todo_id} endpoint.""" + + def test_get_existing_todo(self, client: TestClient) -> None: + """Fetching an existing todo should return its data.""" + created = _create_todo(client, title="Existing") + response = client.get(f"/todos/{created['id']}") + assert response.status_code == 200 + body = response.json() + assert body["id"] == created["id"] + assert body["title"] == "Existing" + + def test_get_todo_not_found(self, client: TestClient) -> None: + """Fetching a non-existent todo should return 404.""" + response = client.get("/todos/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" + + def test_get_todo_not_found_zero(self, client: TestClient) -> None: + """ID 0 never exists (counter starts at 1) — should return 404.""" + response = client.get("/todos/0") + assert response.status_code == 404 + + def test_get_todo_with_description(self, client: TestClient) -> None: + """The description field should be correctly returned.""" + created = _create_todo(client, title="With desc", description="Details") + response = client.get(f"/todos/{created['id']}") + assert response.status_code == 200 + assert response.json()["description"] == "Details" + + +# ------------------------------------------------------------------ +# PUT /todos/{todo_id} +# ------------------------------------------------------------------ + +class TestUpdateTodo: + """Tests for the PUT /todos/{todo_id} endpoint.""" + + def test_update_title(self, client: TestClient) -> None: + """Updating the title should change it and leave other fields intact.""" + created = _create_todo(client, title="Old title") + response = client.put( + f"/todos/{created['id']}", + json={"title": "New title"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["title"] == "New title" + assert body["completed"] == created["completed"] + + def test_update_completed(self, client: TestClient) -> None: + """Updating completed status should toggle the value.""" + created = _create_todo(client, title="Toggle me") + assert created["completed"] is False + + response = client.put( + f"/todos/{created['id']}", + json={"completed": True}, + ) + assert response.status_code == 200 + assert response.json()["completed"] is True + + def test_update_description(self, client: TestClient) -> None: + """Updating description should change it.""" + created = _create_todo(client, title="Desc test") + response = client.put( + f"/todos/{created['id']}", + json={"description": "Added later"}, + ) + assert response.status_code == 200 + assert response.json()["description"] == "Added later" + + def test_update_multiple_fields(self, client: TestClient) -> None: + """Updating multiple fields at once should apply all changes.""" + created = _create_todo(client, title="Multi") + response = client.put( + f"/todos/{created['id']}", + json={ + "title": "Updated multi", + "description": "New desc", + "completed": True, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["title"] == "Updated multi" + assert body["description"] == "New desc" + assert body["completed"] is True + + def test_update_no_fields(self, client: TestClient) -> None: + """Sending an empty update body should succeed without changes.""" + created = _create_todo(client, title="No change") + response = client.put(f"/todos/{created['id']}", json={}) + assert response.status_code == 200 + assert response.json()["title"] == "No change" + + def test_update_not_found(self, client: TestClient) -> None: + """Updating a non-existent todo should return 404.""" + response = client.put("/todos/999", json={"title": "Ghost"}) + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" + + def test_update_preserves_id(self, client: TestClient) -> None: + """The ID must not change after an update.""" + created = _create_todo(client, title="Keep ID") + response = client.put( + f"/todos/{created['id']}", + json={"title": "Still same ID"}, + ) + assert response.json()["id"] == created["id"] + + +# ------------------------------------------------------------------ +# DELETE /todos/{todo_id} +# ------------------------------------------------------------------ + +class TestDeleteTodo: + """Tests for the DELETE /todos/{todo_id} endpoint.""" + + def test_delete_existing_todo(self, client: TestClient) -> None: + """Deleting an existing todo should return 204 No Content.""" + created = _create_todo(client, title="To be deleted") + response = client.delete(f"/todos/{created['id']}") + assert response.status_code == 204 + assert response.content == b"" # No body for 204 + + def test_delete_removes_todo(self, client: TestClient) -> None: + """After deletion the todo must no longer be retrievable.""" + created = _create_todo(client, title="Ephemeral") + todo_id = created["id"] + + client.delete(f"/todos/{todo_id}") + + get_response = client.get(f"/todos/{todo_id}") + assert get_response.status_code == 404 + + def test_delete_removes_from_list(self, client: TestClient) -> None: + """After deletion the todo must not appear in the list.""" + first = _create_todo(client, title="Keep") + second = _create_todo(client, title="Remove") + + client.delete(f"/todos/{second['id']}") + + listing = client.get("/todos").json() + assert len(listing) == 1 + assert listing[0]["id"] == first["id"] + + def test_delete_not_found(self, client: TestClient) -> None: + """Deleting a non-existent todo should return 404.""" + response = client.delete("/todos/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Todo not found" + + def test_delete_twice(self, client: TestClient) -> None: + """Deleting the same todo twice should return 404 on the second call.""" + created = _create_todo(client, title="Double delete") + todo_id = created["id"] + + first_delete = client.delete(f"/todos/{todo_id}") + assert first_delete.status_code == 204 + + second_delete = client.delete(f"/todos/{todo_id}") + assert second_delete.status_code == 404 + + +# ------------------------------------------------------------------ +# Root endpoint +# ------------------------------------------------------------------ + +class TestRootEndpoint: + """Tests for the GET / root endpoint.""" + + def test_root_returns_message(self, client: TestClient) -> None: + """The root endpoint should confirm the API is running.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Todo API is running"} + + +# ------------------------------------------------------------------ +# Store isolation between tests +# ------------------------------------------------------------------ + +class TestStoreIsolation: + """Verify the store is properly reset between tests.""" + + def test_isolation_first(self, client: TestClient) -> None: + """Create a todo — the next test should NOT see it.""" + _create_todo(client, title="Should not leak") + listing = client.get("/todos").json() + assert len(listing) == 1 + + def test_isolation_second(self, client: TestClient) -> None: + """The store should be empty after the fixture reset.""" + listing = client.get("/todos").json() + assert len(listing) == 0 + + def test_isolation_ids_reset(self, client: TestClient) -> None: + """IDs should restart from 1 after a store reset.""" + created = _create_todo(client, title="Fresh start") + assert created["id"] == 1