From 6ce47d0a82256db71407c30f79d7f119c2826a28 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 20:20:41 +0000 Subject: [PATCH 1/3] feat: Todo models and in-memory storage Run: 00c34d11-e23f-4b73-ae03-09ecd687134d Task: 53a26fa6-2d82-4262-a56f-874c4841c893 Agent: builder --- PLANNING.md | 95 ++++++++++++++++++++ app/__init__.py | 0 app/models.py | 65 ++++++++++++++ app/storage.py | 124 +++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_models.py | 121 +++++++++++++++++++++++++ tests/test_planning_doc.py | 66 ++++++++++++++ tests/test_storage.py | 179 +++++++++++++++++++++++++++++++++++++ 8 files changed, 650 insertions(+) create mode 100644 PLANNING.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/storage.py create mode 100644 tests/__init__.py create mode 100644 tests/test_models.py create mode 100644 tests/test_planning_doc.py create mode 100644 tests/test_storage.py diff --git a/PLANNING.md b/PLANNING.md new file mode 100644 index 0000000..8418431 --- /dev/null +++ b/PLANNING.md @@ -0,0 +1,95 @@ +# Todo API — Architecture Plan + +## Overview + +A FastAPI-based Todo CRUD API with in-memory dict storage. The API +exposes RESTful endpoints for managing todo items. Storage is ephemeral +and resets on server restart. + +## Project Structure + +``` +app/ +├── __init__.py # Package marker +├── main.py # FastAPI application entry point, includes router +├── models.py # Pydantic schemas: TodoCreate, TodoUpdate, TodoResponse +├── storage.py # In-memory storage class with dict and auto-incrementing ID +└── routes.py # API route definitions +tests/ +├── test_models.py +├── test_storage.py +└── test_planning_doc.py +PLANNING.md # This file +``` + +## Data Model + +Each todo item has the following fields: + +| Field | Type | Description | +|---------------|-----------------|--------------------------------------| +| `id` | `int` | Auto-generated unique identifier | +| `title` | `str` | Title of the todo item (required) | +| `description` | `str \| None` | Optional longer description | +| `completed` | `bool` | Completion status (default: `False`) | + +## Pydantic Schemas + +### TodoCreate + +Used in `POST /todos` request bodies. + +- `title: str` — required, min length 1 +- `description: str | None = None` — optional + +### TodoUpdate + +Used in `PUT /todos/{id}` request bodies. All fields optional; only +provided fields are updated (partial update semantics). + +- `title: str | None = None` +- `description: str | None = None` +- `completed: bool | None = None` + +### TodoResponse + +Returned from all endpoints that produce todo data. + +- `id: int` +- `title: str` +- `description: str | None` +- `completed: bool` + +## API Endpoints + +| Method | Path | Description | Success | Error | +|----------|-------------------|--------------------------|---------|-------| +| `GET` | `/todos` | List all todo items | 200 | — | +| `GET` | `/todos/{id}` | Get a single todo by ID | 200 | 404 | +| `POST` | `/todos` | Create a new todo item | 201 | 422 | +| `PUT` | `/todos/{id}` | Update an existing todo | 200 | 404 | +| `DELETE` | `/todos/{id}` | Delete a todo by ID | 200 | 404 | + +## Storage + +The storage layer uses a Python **dict** (`dict[int, dict]`) as the +in-memory data store. An auto-incrementing integer counter (`_next_id`) +assigns unique IDs to new items. + +The `TodoStorage` class exposes five methods: + +- `get_all() -> list[TodoResponse]` +- `get_by_id(todo_id: int) -> TodoResponse | None` +- `create(todo: TodoCreate) -> TodoResponse` +- `update(todo_id: int, todo: TodoUpdate) -> TodoResponse | None` +- `delete(todo_id: int) -> bool` + +**Note:** Storage is ephemeral. All data is lost when the server process +restarts. + +## Error Handling + +- **404 Not Found** — returned by `GET /todos/{id}`, `PUT /todos/{id}`, + and `DELETE /todos/{id}` when no todo with the given ID exists. +- **422 Unprocessable Entity** — returned by `POST /todos` and + `PUT /todos/{id}` when the request body fails validation. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..8770025 --- /dev/null +++ b/app/models.py @@ -0,0 +1,65 @@ +"""Pydantic models for the Todo API. + +Defines request/response schemas used across the application: +- TodoCreate: fields required when creating a new todo +- TodoUpdate: optional fields for partial updates +- TodoResponse: full todo representation returned to clients +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class TodoCreate(BaseModel): + """Schema for creating a new todo item. + + Attributes: + title: The title of the todo item. Required. + description: An optional longer description of the todo item. + """ + + title: str = Field(..., min_length=1, description="Title of the todo item") + description: Optional[str] = Field( + default=None, description="Optional description of the todo item" + ) + + +class TodoUpdate(BaseModel): + """Schema for updating an existing todo item. + + All fields are optional; only provided fields will be updated. + + Attributes: + title: New title for the todo item. + description: New description for the todo item. + completed: New completion status for the todo item. + """ + + title: Optional[str] = Field( + default=None, min_length=1, description="New title of the todo item" + ) + description: Optional[str] = Field( + default=None, description="New description of the todo item" + ) + completed: Optional[bool] = Field( + default=None, description="New completion status" + ) + + +class TodoResponse(BaseModel): + """Schema for a todo item returned in API responses. + + Attributes: + id: The unique identifier of the todo item. + title: The title of the todo item. + description: The optional description of the todo item. + completed: Whether the todo item has been completed. + """ + + id: int + title: str + description: Optional[str] = None + completed: bool diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..ad727ad --- /dev/null +++ b/app/storage.py @@ -0,0 +1,124 @@ +"""In-memory storage for Todo items. + +Provides a class-based store backed by a Python dict with an +auto-incrementing integer ID counter. Storage is ephemeral and +resets when the process restarts. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from app.models import TodoCreate, TodoResponse, TodoUpdate + + +class TodoStorage: + """In-memory CRUD store for todo items. + + Attributes: + _todos: Internal dict mapping todo id -> todo data dict. + _next_id: Auto-incrementing counter for generating unique IDs. + """ + + def __init__(self) -> None: + """Initialise an empty store with the ID counter starting at 1.""" + self._todos: Dict[int, dict] = {} + self._next_id: int = 1 + + def _allocate_id(self) -> int: + """Return the next available ID and increment the counter. + + Returns: + The newly allocated integer ID. + """ + current_id = self._next_id + self._next_id += 1 + return current_id + + def get_all(self) -> List[TodoResponse]: + """Return all todo items in the store. + + Returns: + A list of TodoResponse objects. Returns an empty list when + the store contains no items. + """ + return [ + TodoResponse(**todo_data) for todo_data in self._todos.values() + ] + + def get_by_id(self, todo_id: int) -> Optional[TodoResponse]: + """Return a single todo item by its ID. + + Args: + todo_id: The unique identifier of the todo to retrieve. + + Returns: + A TodoResponse if found, or None if no item with the given + ID exists. + """ + todo_data = self._todos.get(todo_id) + if todo_data is None: + return None + return TodoResponse(**todo_data) + + def create(self, todo: TodoCreate) -> TodoResponse: + """Create a new todo item and return it. + + The item is assigned an auto-incrementing ID and its completed + status defaults to False. + + Args: + todo: The creation payload containing title and optional + description. + + Returns: + A TodoResponse representing the newly created item. + """ + new_id = self._allocate_id() + todo_data: dict = { + "id": new_id, + "title": todo.title, + "description": todo.description, + "completed": False, + } + self._todos[new_id] = todo_data + return TodoResponse(**todo_data) + + def update(self, todo_id: int, todo: TodoUpdate) -> Optional[TodoResponse]: + """Update an existing todo item with the provided fields. + + Only fields that are explicitly set (not None) in the update + payload will be modified; all other fields remain unchanged. + + Args: + todo_id: The unique identifier of the todo to update. + todo: The update payload with optional fields. + + Returns: + A TodoResponse with the updated data, or None if no item + with the given ID exists. + """ + existing = self._todos.get(todo_id) + if existing is None: + return None + + update_fields = todo.model_dump(exclude_unset=True) + for field, value in update_fields.items(): + existing[field] = value + + return TodoResponse(**existing) + + def delete(self, todo_id: int) -> bool: + """Delete a todo item by its ID. + + Args: + todo_id: The unique identifier of the todo to delete. + + Returns: + True if the item was found and deleted, False if no item + with the given ID exists. + """ + if todo_id not in self._todos: + return False + del self._todos[todo_id] + return True diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..4f1efc3 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,121 @@ +"""Tests for app.models Pydantic schemas.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.models import TodoCreate, TodoResponse, TodoUpdate + + +# --------------------------------------------------------------------------- +# TodoCreate +# --------------------------------------------------------------------------- + + +class TestTodoCreate: + """Tests for the TodoCreate schema.""" + + def test_create_with_title_only(self) -> None: + """TodoCreate can be instantiated with just a title.""" + todo = TodoCreate(title="Buy milk") + assert todo.title == "Buy milk" + assert todo.description is None + + def test_create_with_title_and_description(self) -> None: + """TodoCreate accepts an optional description.""" + todo = TodoCreate(title="Buy milk", description="From the store") + assert todo.title == "Buy milk" + assert todo.description == "From the store" + + def test_create_requires_title(self) -> None: + """TodoCreate raises ValidationError when title is missing.""" + with pytest.raises(ValidationError): + TodoCreate() # type: ignore[call-arg] + + def test_create_rejects_empty_title(self) -> None: + """TodoCreate rejects an empty string title.""" + with pytest.raises(ValidationError): + TodoCreate(title="") + + +# --------------------------------------------------------------------------- +# TodoUpdate +# --------------------------------------------------------------------------- + + +class TestTodoUpdate: + """Tests for the TodoUpdate schema.""" + + def test_update_all_none_by_default(self) -> None: + """TodoUpdate fields default to None when not provided.""" + update = TodoUpdate() + assert update.title is None + assert update.description is None + assert update.completed is None + + def test_update_partial_fields(self) -> None: + """TodoUpdate allows setting a subset of fields.""" + update = TodoUpdate(completed=True) + assert update.title is None + assert update.description is None + assert update.completed is True + + def test_update_all_fields(self) -> None: + """TodoUpdate accepts all fields simultaneously.""" + update = TodoUpdate( + title="New title", description="New desc", completed=False + ) + assert update.title == "New title" + assert update.description == "New desc" + assert update.completed is False + + def test_update_rejects_empty_title(self) -> None: + """TodoUpdate rejects an empty string title when provided.""" + with pytest.raises(ValidationError): + TodoUpdate(title="") + + def test_update_model_dump_exclude_unset(self) -> None: + """model_dump(exclude_unset=True) only includes explicitly set fields.""" + update = TodoUpdate(completed=True) + dumped = update.model_dump(exclude_unset=True) + assert dumped == {"completed": True} + + +# --------------------------------------------------------------------------- +# TodoResponse +# --------------------------------------------------------------------------- + + +class TestTodoResponse: + """Tests for the TodoResponse schema.""" + + def test_response_all_fields(self) -> None: + """TodoResponse can be created with all fields.""" + resp = TodoResponse( + id=1, title="Test", description="Desc", completed=False + ) + assert resp.id == 1 + assert resp.title == "Test" + assert resp.description == "Desc" + assert resp.completed is False + + def test_response_description_defaults_to_none(self) -> None: + """TodoResponse defaults description to None when omitted.""" + resp = TodoResponse(id=1, title="Test", completed=True) + assert resp.description is None + + def test_response_requires_id(self) -> None: + """TodoResponse raises ValidationError when id is missing.""" + with pytest.raises(ValidationError): + TodoResponse(title="Test", completed=False) # type: ignore[call-arg] + + def test_response_requires_title(self) -> None: + """TodoResponse raises ValidationError when title is missing.""" + with pytest.raises(ValidationError): + TodoResponse(id=1, completed=False) # type: ignore[call-arg] + + def test_response_requires_completed(self) -> None: + """TodoResponse raises ValidationError when completed is missing.""" + with pytest.raises(ValidationError): + TodoResponse(id=1, title="Test") # type: ignore[call-arg] diff --git a/tests/test_planning_doc.py b/tests/test_planning_doc.py new file mode 100644 index 0000000..797b1da --- /dev/null +++ b/tests/test_planning_doc.py @@ -0,0 +1,66 @@ +"""Tests to verify the PLANNING.md document exists and is complete.""" + +from __future__ import annotations + +from pathlib import Path + +PLANNING_PATH = Path("PLANNING.md") + + +def _read_planning() -> str: + """Read and return the content of PLANNING.md. + + Returns: + The full text content of the planning document. + """ + return PLANNING_PATH.read_text(encoding="utf-8") + + +def test_planning_md_exists() -> None: + """PLANNING.md must exist at the repository root.""" + assert PLANNING_PATH.exists(), "PLANNING.md not found at repository root" + + +def test_planning_md_contains_all_sections() -> None: + """PLANNING.md must contain all required section headings.""" + content = _read_planning() + required_sections = [ + "Overview", + "Project Structure", + "Data Model", + "Pydantic Schemas", + "API Endpoints", + "Storage", + "Error Handling", + ] + for section in required_sections: + assert section in content, f"Missing section: {section}" + + +def test_planning_md_contains_all_endpoints() -> None: + """PLANNING.md must document all five CRUD endpoints.""" + content = _read_planning() + expected_endpoints = [ + "GET", + "POST", + "PUT", + "DELETE", + "/todos", + "/todos/{id}", + ] + for endpoint in expected_endpoints: + assert endpoint in content, f"Missing endpoint reference: {endpoint}" + + +def test_planning_md_contains_pydantic_schemas() -> None: + """PLANNING.md must reference all three Pydantic schemas.""" + content = _read_planning() + for schema in ["TodoCreate", "TodoUpdate", "TodoResponse"]: + assert schema in content, f"Missing schema reference: {schema}" + + +def test_planning_md_contains_data_model_fields() -> None: + """PLANNING.md must document all four data model fields.""" + content = _read_planning() + for field in ["id", "title", "description", "completed"]: + assert field in content, f"Missing data model field: {field}" diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..b8dbcb1 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,179 @@ +"""Tests for app.storage in-memory TodoStorage.""" + +from __future__ import annotations + +import pytest + +from app.models import TodoCreate, TodoUpdate +from app.storage import TodoStorage + + +@pytest.fixture() +def store() -> TodoStorage: + """Return a fresh TodoStorage instance for each test.""" + return TodoStorage() + + +# --------------------------------------------------------------------------- +# get_all +# --------------------------------------------------------------------------- + + +class TestGetAll: + """Tests for TodoStorage.get_all.""" + + def test_empty_store_returns_empty_list(self, store: TodoStorage) -> None: + """get_all returns an empty list when no items exist.""" + assert store.get_all() == [] + + def test_returns_all_created_items(self, store: TodoStorage) -> None: + """get_all returns every item that has been created.""" + store.create(TodoCreate(title="First")) + store.create(TodoCreate(title="Second")) + result = store.get_all() + assert len(result) == 2 + titles = {item.title for item in result} + assert titles == {"First", "Second"} + + +# --------------------------------------------------------------------------- +# get_by_id +# --------------------------------------------------------------------------- + + +class TestGetById: + """Tests for TodoStorage.get_by_id.""" + + def test_returns_none_for_nonexistent_id(self, store: TodoStorage) -> None: + """get_by_id returns None when the ID does not exist.""" + assert store.get_by_id(999) is None + + def test_returns_correct_item(self, store: TodoStorage) -> None: + """get_by_id returns the item matching the given ID.""" + created = store.create(TodoCreate(title="Test")) + fetched = store.get_by_id(created.id) + assert fetched is not None + assert fetched.id == created.id + assert fetched.title == "Test" + + +# --------------------------------------------------------------------------- +# create +# --------------------------------------------------------------------------- + + +class TestCreate: + """Tests for TodoStorage.create.""" + + def test_create_assigns_incrementing_ids(self, store: TodoStorage) -> None: + """Each created item receives a unique, incrementing ID.""" + first = store.create(TodoCreate(title="A")) + second = store.create(TodoCreate(title="B")) + assert first.id == 1 + assert second.id == 2 + + def test_create_defaults_completed_to_false(self, store: TodoStorage) -> None: + """Newly created items have completed=False.""" + item = store.create(TodoCreate(title="Test")) + assert item.completed is False + + def test_create_stores_description(self, store: TodoStorage) -> None: + """The description field is persisted when provided.""" + item = store.create( + TodoCreate(title="Test", description="Details here") + ) + assert item.description == "Details here" + + def test_create_description_defaults_to_none(self, store: TodoStorage) -> None: + """The description field defaults to None when omitted.""" + item = store.create(TodoCreate(title="Test")) + assert item.description is None + + +# --------------------------------------------------------------------------- +# update +# --------------------------------------------------------------------------- + + +class TestUpdate: + """Tests for TodoStorage.update.""" + + def test_update_nonexistent_returns_none(self, store: TodoStorage) -> None: + """update returns None when the ID does not exist.""" + result = store.update(999, TodoUpdate(title="Nope")) + assert result is None + + def test_update_title(self, store: TodoStorage) -> None: + """update modifies the title when provided.""" + created = store.create(TodoCreate(title="Old")) + updated = store.update(created.id, TodoUpdate(title="New")) + assert updated is not None + assert updated.title == "New" + + def test_update_completed(self, store: TodoStorage) -> None: + """update modifies the completed status when provided.""" + created = store.create(TodoCreate(title="Task")) + updated = store.update(created.id, TodoUpdate(completed=True)) + assert updated is not None + assert updated.completed is True + + def test_update_description(self, store: TodoStorage) -> None: + """update modifies the description when provided.""" + created = store.create(TodoCreate(title="Task")) + updated = store.update(created.id, TodoUpdate(description="Added")) + assert updated is not None + assert updated.description == "Added" + + def test_update_preserves_unset_fields(self, store: TodoStorage) -> None: + """Fields not included in the update payload remain unchanged.""" + created = store.create( + TodoCreate(title="Keep me", description="Keep this too") + ) + updated = store.update(created.id, TodoUpdate(completed=True)) + assert updated is not None + assert updated.title == "Keep me" + assert updated.description == "Keep this too" + assert updated.completed is True + + def test_update_multiple_fields_at_once(self, store: TodoStorage) -> None: + """Multiple fields can be updated in a single call.""" + created = store.create(TodoCreate(title="Original")) + updated = store.update( + created.id, + TodoUpdate(title="Changed", description="New desc", completed=True), + ) + assert updated is not None + assert updated.title == "Changed" + assert updated.description == "New desc" + assert updated.completed is True + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +class TestDelete: + """Tests for TodoStorage.delete.""" + + def test_delete_nonexistent_returns_false(self, store: TodoStorage) -> None: + """delete returns False when the ID does not exist.""" + assert store.delete(999) is False + + def test_delete_existing_returns_true(self, store: TodoStorage) -> None: + """delete returns True when the item is successfully removed.""" + created = store.create(TodoCreate(title="Doomed")) + assert store.delete(created.id) is True + + def test_delete_removes_item_from_store(self, store: TodoStorage) -> None: + """After deletion the item is no longer retrievable.""" + created = store.create(TodoCreate(title="Gone")) + store.delete(created.id) + assert store.get_by_id(created.id) is None + assert len(store.get_all()) == 0 + + def test_delete_same_id_twice_returns_false(self, store: TodoStorage) -> None: + """Deleting the same ID a second time returns False.""" + created = store.create(TodoCreate(title="Once")) + assert store.delete(created.id) is True + assert store.delete(created.id) is False From 0ae669037fc5c78affaa8adf2aa4a30fa25f9bef Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 20:23:13 +0000 Subject: [PATCH 2/3] feat: Todo CRUD router Run: 00c34d11-e23f-4b73-ae03-09ecd687134d Task: a4899826-7aa4-4586-a213-6fedaf4bf0f5 Agent: builder --- app/main.py | 18 ++++ app/routes.py | 120 +++++++++++++++++++++++ app/storage.py | 6 ++ tests/test_routes.py | 220 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 app/main.py create mode 100644 app/routes.py create mode 100644 tests/test_routes.py diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..5927035 --- /dev/null +++ b/app/main.py @@ -0,0 +1,18 @@ +"""FastAPI application entry point. + +Creates the FastAPI app instance and registers the todo CRUD router. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from app.routes import router + +app = FastAPI( + title="Todo API", + description="A simple Todo REST API with in-memory storage.", + version="1.0.0", +) + +app.include_router(router) diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..0f2d3bf --- /dev/null +++ b/app/routes.py @@ -0,0 +1,120 @@ +"""API router for Todo CRUD endpoints. + +Defines all REST endpoints for managing todo items: +- GET /todos – list all todos +- GET /todos/{todo_id} – retrieve a single todo +- POST /todos – create a new todo +- PUT /todos/{todo_id} – update an existing todo +- DELETE /todos/{todo_id} – delete a todo +""" + +from __future__ import annotations + +from typing import List + +from fastapi import APIRouter, HTTPException, status + +from app.models import TodoCreate, TodoResponse, TodoUpdate +from app.storage import storage + +router = APIRouter() + + +@router.get("/todos", response_model=List[TodoResponse], tags=["todos"]) +async def list_todos() -> List[TodoResponse]: + """Return all todo items. + + Returns: + A list of all todos currently in storage. Returns an empty + list when no todos exist. + """ + return storage.get_all() + + +@router.get("/todos/{todo_id}", response_model=TodoResponse, tags=["todos"]) +async def get_todo(todo_id: int) -> TodoResponse: + """Return a single todo item by its ID. + + Args: + todo_id: The unique identifier of the requested todo. + + Returns: + The matching TodoResponse. + + Raises: + HTTPException: 404 if no todo with the given ID exists. + """ + todo = storage.get_by_id(todo_id) + if todo is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo not found", + ) + return todo + + +@router.post( + "/todos", + response_model=TodoResponse, + status_code=status.HTTP_201_CREATED, + tags=["todos"], +) +async def create_todo(payload: TodoCreate) -> TodoResponse: + """Create a new todo item. + + Args: + payload: The creation payload containing title and optional + description. + + Returns: + The newly created TodoResponse with a 201 status code. + """ + return storage.create(payload) + + +@router.put("/todos/{todo_id}", response_model=TodoResponse, tags=["todos"]) +async def update_todo(todo_id: int, payload: TodoUpdate) -> TodoResponse: + """Update an existing todo item. + + Only fields provided in the request body are updated; omitted + fields remain unchanged. + + Args: + todo_id: The unique identifier of the todo to update. + payload: The update payload with optional fields. + + Returns: + The updated TodoResponse. + + Raises: + HTTPException: 404 if no todo with the given ID exists. + """ + todo = storage.update(todo_id, payload) + if todo is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo not found", + ) + return todo + + +@router.delete( + "/todos/{todo_id}", + status_code=status.HTTP_204_NO_CONTENT, + tags=["todos"], +) +async def delete_todo(todo_id: int) -> None: + """Delete a todo item by its ID. + + Args: + todo_id: The unique identifier of the todo to delete. + + Raises: + HTTPException: 404 if no todo with the given ID exists. + """ + deleted = storage.delete(todo_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo not found", + ) diff --git a/app/storage.py b/app/storage.py index ad727ad..ca06df4 100644 --- a/app/storage.py +++ b/app/storage.py @@ -3,6 +3,9 @@ Provides a class-based store backed by a Python dict with an auto-incrementing integer ID counter. Storage is ephemeral and resets when the process restarts. + +A module-level ``storage`` instance is provided for convenient use +across the application. """ from __future__ import annotations @@ -122,3 +125,6 @@ def delete(self, todo_id: int) -> bool: return False del self._todos[todo_id] return True + + +storage = TodoStorage() diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..a760972 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,220 @@ +"""Tests for the Todo CRUD API endpoints. + +Covers all five endpoints with happy-path and error scenarios, +including 404 handling and correct HTTP status codes. +""" + +from __future__ import annotations + +from fastapi import status +from fastapi.testclient import TestClient + +from app.main import app +from app.storage import storage + +client = TestClient(app) + + +def _reset_storage() -> None: + """Clear the in-memory store and reset the ID counter.""" + storage._todos.clear() + storage._next_id = 1 + + +# -- GET /todos ------------------------------------------------------------- + + +def test_list_todos_empty() -> None: + """GET /todos returns an empty list when no todos exist.""" + _reset_storage() + response = client.get("/todos") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + +def test_list_todos_returns_all() -> None: + """GET /todos returns all created todos.""" + _reset_storage() + client.post("/todos", json={"title": "First"}) + client.post("/todos", json={"title": "Second"}) + + response = client.get("/todos") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 + titles = {item["title"] for item in data} + assert titles == {"First", "Second"} + + +# -- GET /todos/{todo_id} --------------------------------------------------- + + +def test_get_todo_existing() -> None: + """GET /todos/{id} returns the correct todo.""" + _reset_storage() + 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 == status.HTTP_200_OK + assert response.json()["title"] == "Test" + assert response.json()["id"] == todo_id + + +def test_get_todo_not_found() -> None: + """GET /todos/{id} returns 404 for a non-existent id.""" + _reset_storage() + response = client.get("/todos/999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Todo not found" + + +# -- POST /todos ------------------------------------------------------------ + + +def test_create_todo_minimal() -> None: + """POST /todos with only a title returns 201 and correct defaults.""" + _reset_storage() + response = client.post("/todos", json={"title": "Buy milk"}) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["title"] == "Buy milk" + assert data["description"] is None + assert data["completed"] is False + assert "id" in data + + +def test_create_todo_with_description() -> None: + """POST /todos with title and description returns both.""" + _reset_storage() + response = client.post( + "/todos", + json={"title": "Read book", "description": "Chapter 3"}, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["title"] == "Read book" + assert data["description"] == "Chapter 3" + + +def test_create_todo_empty_title_rejected() -> None: + """POST /todos with an empty title returns 422 validation error.""" + _reset_storage() + response = client.post("/todos", json={"title": ""}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_create_todo_missing_title_rejected() -> None: + """POST /todos without a title field returns 422 validation error.""" + _reset_storage() + response = client.post("/todos", json={}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_create_todo_ids_auto_increment() -> None: + """Successive POST /todos calls produce incrementing IDs.""" + _reset_storage() + resp1 = client.post("/todos", json={"title": "A"}) + resp2 = client.post("/todos", json={"title": "B"}) + assert resp1.json()["id"] < resp2.json()["id"] + + +# -- PUT /todos/{todo_id} --------------------------------------------------- + + +def test_update_todo_title() -> None: + """PUT /todos/{id} updates the title and preserves other fields.""" + _reset_storage() + create_resp = client.post( + "/todos", + json={"title": "Old title", "description": "Keep me"}, + ) + todo_id = create_resp.json()["id"] + + response = client.put( + f"/todos/{todo_id}", + json={"title": "New title"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == "New title" + assert data["description"] == "Keep me" + assert data["completed"] is False + + +def test_update_todo_completed() -> None: + """PUT /todos/{id} can toggle the completed flag.""" + _reset_storage() + 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 == status.HTTP_200_OK + assert response.json()["completed"] is True + + +def test_update_todo_not_found() -> None: + """PUT /todos/{id} returns 404 for a non-existent id.""" + _reset_storage() + response = client.put("/todos/999", json={"title": "Nope"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Todo not found" + + +def test_update_todo_partial_preserves_unset_fields() -> None: + """PUT /todos/{id} with only description leaves title and completed intact.""" + _reset_storage() + create_resp = client.post("/todos", json={"title": "Keep"}) + todo_id = create_resp.json()["id"] + + response = client.put( + f"/todos/{todo_id}", + json={"description": "Added desc"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == "Keep" + assert data["description"] == "Added desc" + assert data["completed"] is False + + +# -- DELETE /todos/{todo_id} ------------------------------------------------ + + +def test_delete_todo_existing() -> None: + """DELETE /todos/{id} returns 204 and removes the item.""" + _reset_storage() + create_resp = client.post("/todos", json={"title": "Delete me"}) + todo_id = create_resp.json()["id"] + + response = client.delete(f"/todos/{todo_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.content == b"" + + # Confirm it's gone + get_resp = client.get(f"/todos/{todo_id}") + assert get_resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_todo_not_found() -> None: + """DELETE /todos/{id} returns 404 for a non-existent id.""" + _reset_storage() + response = client.delete("/todos/999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Todo not found" + + +def test_delete_todo_twice_returns_404_second_time() -> None: + """Deleting the same todo twice returns 204 then 404.""" + _reset_storage() + create_resp = client.post("/todos", json={"title": "Once"}) + todo_id = create_resp.json()["id"] + + first = client.delete(f"/todos/{todo_id}") + assert first.status_code == status.HTTP_204_NO_CONTENT + + second = client.delete(f"/todos/{todo_id}") + assert second.status_code == status.HTTP_404_NOT_FOUND From bcf4e911047a0450accee5feae4b8984c508cee4 Mon Sep 17 00:00:00 2001 From: FORGE Date: Tue, 7 Apr 2026 20:25:26 +0000 Subject: [PATCH 3/3] feat: RUNNING.md and seed data Run: 00c34d11-e23f-4b73-ae03-09ecd687134d Task: 3a19180a-2779-4abf-9059-de729e741296 Agent: builder --- RUNNING.md | 58 ++++++++++++++++++++++++++++++++++++ app/__init__.py | 1 + app/storage.py | 43 +++++++++++++++++++++++++-- main.py | 26 +++++++++++++++++ requirements.txt | 3 ++ tests/__init__.py | 1 + tests/test_running_md.py | 37 +++++++++++++++++++++++ tests/test_seed_data.py | 63 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 RUNNING.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tests/test_running_md.py create mode 100644 tests/test_seed_data.py diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 0000000..ebcc439 --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,58 @@ +# Running the Todo API + +This document explains how to install dependencies and run the +Todo API application locally. + +## Prerequisites + +- Python 3.10 or later +- `pip` (bundled with Python) + +## 1. Install dependencies + +```bash +pip install -r requirements.txt +``` + +## 2. Run the application + +You can start the server in either of two ways: + +### Option A — Using the convenience entry point + +```bash +python main.py +``` + +This starts Uvicorn on **http://127.0.0.1:8000** with auto-reload +enabled. + +### Option B — Using Uvicorn directly + +```bash +uvicorn app.main:app --reload +``` + +## 3. Explore the API + +Once the server is running you can: + +- Open the interactive docs at **http://127.0.0.1:8000/docs** +- Open the alternative docs at **http://127.0.0.1:8000/redoc** +- List all todos: `GET /todos` +- Get a single todo: `GET /todos/{id}` +- Create a todo: `POST /todos` +- Update a todo: `PUT /todos/{id}` +- Delete a todo: `DELETE /todos/{id}` + +## 4. Seed data + +The application ships with a few example todos so the demo is not +empty on first launch. These are loaded into in-memory storage when +the module is imported and will reset every time the server restarts. + +## 5. Running the tests + +```bash +python -m pytest tests/ -v +``` diff --git a/app/__init__.py b/app/__init__.py index e69de29..b9586db 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Todo API application package.""" diff --git a/app/storage.py b/app/storage.py index ca06df4..066f865 100644 --- a/app/storage.py +++ b/app/storage.py @@ -5,7 +5,8 @@ resets when the process restarts. A module-level ``storage`` instance is provided for convenient use -across the application. +across the application. The instance is pre-populated with a handful +of seed todos so the demo is never empty on first launch. """ from __future__ import annotations @@ -127,4 +128,42 @@ def delete(self, todo_id: int) -> bool: return True -storage = TodoStorage() +def _build_seeded_storage() -> TodoStorage: + """Create a TodoStorage instance pre-populated with demo items. + + Seed items give new users something to see immediately when they + start the application for the first time. + + Returns: + A TodoStorage containing a few example todo items. + """ + store = TodoStorage() + + seed_items: List[TodoCreate] = [ + TodoCreate( + title="Buy groceries", + description="Milk, eggs, bread, and fresh vegetables", + ), + TodoCreate( + title="Read FastAPI documentation", + description="Review the official tutorial and advanced user guide", + ), + TodoCreate( + title="Write unit tests", + description="Achieve at least 90% coverage on the API routes", + ), + ] + + for item in seed_items: + store.create(item) + + # Mark the second item as completed to showcase mixed state + store.update( + 2, + TodoUpdate(completed=True), + ) + + return store + + +storage: TodoStorage = _build_seeded_storage() diff --git a/main.py b/main.py new file mode 100644 index 0000000..c159486 --- /dev/null +++ b/main.py @@ -0,0 +1,26 @@ +"""Convenience entry point for running the Todo API. + +Usage:: + + python main.py + +Starts Uvicorn on 127.0.0.1:8000 with auto-reload enabled. +""" + +from __future__ import annotations + +import uvicorn + + +def main() -> None: + """Launch the Uvicorn server pointing at the FastAPI application.""" + uvicorn.run( + "app.main:app", + host="127.0.0.1", + port=8000, + reload=True, + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0685249 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110,<1 +uvicorn[standard]>=0.29,<1 +pydantic>=2,<3 diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..e0d1acf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for the Todo API.""" diff --git a/tests/test_running_md.py b/tests/test_running_md.py new file mode 100644 index 0000000..502eb28 --- /dev/null +++ b/tests/test_running_md.py @@ -0,0 +1,37 @@ +"""Tests to verify RUNNING.md exists and contains required sections.""" + +from __future__ import annotations + +from pathlib import Path + +RUNNING_MD = Path(__file__).resolve().parent.parent / "RUNNING.md" + + +def test_running_md_exists() -> None: + """RUNNING.md must be present at the repository root.""" + assert RUNNING_MD.exists(), "RUNNING.md not found at the repository root" + + +def test_running_md_contains_install_instructions() -> None: + """RUNNING.md must document how to install dependencies.""" + content = RUNNING_MD.read_text(encoding="utf-8") + assert "pip install -r requirements.txt" in content + + +def test_running_md_contains_run_instructions() -> None: + """RUNNING.md must document how to start the application.""" + content = RUNNING_MD.read_text(encoding="utf-8") + assert "python main.py" in content + assert "uvicorn" in content + + +def test_running_md_mentions_seed_data() -> None: + """RUNNING.md must mention that seed data is pre-loaded.""" + content = RUNNING_MD.read_text(encoding="utf-8") + assert "seed" in content.lower() + + +def test_running_md_contains_test_instructions() -> None: + """RUNNING.md must explain how to run the test suite.""" + content = RUNNING_MD.read_text(encoding="utf-8") + assert "pytest" in content diff --git a/tests/test_seed_data.py b/tests/test_seed_data.py new file mode 100644 index 0000000..b899840 --- /dev/null +++ b/tests/test_seed_data.py @@ -0,0 +1,63 @@ +"""Tests verifying that the module-level storage instance contains seed data.""" + +from __future__ import annotations + +from app.models import TodoCreate, TodoUpdate +from app.storage import TodoStorage, _build_seeded_storage, storage + + +def test_module_storage_is_seeded() -> None: + """The module-level storage instance must contain seed todos on import.""" + todos = storage.get_all() + assert len(todos) > 0, "Module-level storage should not be empty" + + +def test_seed_count() -> None: + """The seeded storage must contain exactly 3 demo items.""" + store = _build_seeded_storage() + assert len(store.get_all()) == 3 + + +def test_seed_titles() -> None: + """Verify the titles of the three seed items.""" + store = _build_seeded_storage() + titles = {todo.title for todo in store.get_all()} + assert "Buy groceries" in titles + assert "Read FastAPI documentation" in titles + assert "Write unit tests" in titles + + +def test_seed_has_completed_item() -> None: + """At least one seed item should be marked as completed.""" + store = _build_seeded_storage() + completed = [todo for todo in store.get_all() if todo.completed] + assert len(completed) >= 1, "Expected at least one completed seed todo" + + +def test_seed_ids_are_sequential() -> None: + """Seed item IDs should be 1, 2, 3.""" + store = _build_seeded_storage() + ids = sorted(todo.id for todo in store.get_all()) + assert ids == [1, 2, 3] + + +def test_seed_descriptions_populated() -> None: + """All seed items should have non-None descriptions.""" + store = _build_seeded_storage() + for todo in store.get_all(): + assert todo.description is not None, ( + f"Seed todo '{todo.title}' is missing a description" + ) + + +def test_fresh_storage_is_empty() -> None: + """A freshly constructed TodoStorage (without seeding) must be empty.""" + store = TodoStorage() + assert len(store.get_all()) == 0 + + +def test_seed_next_id_continues_after_seeds() -> None: + """Creating a new item after seeding must use id=4.""" + store = _build_seeded_storage() + new_todo = store.create(TodoCreate(title="Fourth item")) + assert new_todo.id == 4