From 0cdb0d0b7b9705935aeb79a8e4c12e2f2e689208 Mon Sep 17 00:00:00 2001 From: Aleks Volochnev Date: Fri, 9 Oct 2026 14:17:57 +0200 Subject: [PATCH 1/2] Add text search to the todo list endpoint --- backend/app/routes/todos.py | 23 +++++++++++++++++- backend/tests/test_todos.py | 47 +++++++++++++++++++++++++++++++++++++ docs/api-reference.md | 2 ++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/todos.py b/backend/app/routes/todos.py index 50ab643..98e79b1 100644 --- a/backend/app/routes/todos.py +++ b/backend/app/routes/todos.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -13,10 +13,22 @@ router = APIRouter(prefix="/api/todos", tags=["todos"]) +def _like_pattern(term: str) -> str: + """Build a case-insensitive "contains" pattern, escaping LIKE wildcards in the term.""" + escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + @router.get("", response_model=list[TodoResponse]) async def list_todos( completed: bool | None = Query(None, description="Filter by completion status"), include_archived: bool = Query(False, description="Include archived todos"), + search: str | None = Query( + None, + min_length=1, + max_length=100, + description="Only return todos whose title or description contains this text", + ), db: AsyncSession = Depends(get_db), ) -> list[Todo]: """Retrieve all todos, optionally filtered by completion status.""" @@ -27,6 +39,15 @@ async def list_todos( if not include_archived: query = query.where(Todo.archived_at.is_(None)) + + if search: + pattern = _like_pattern(search) + query = query.where( + or_( + Todo.title.ilike(pattern, escape="\\"), + Todo.description.ilike(pattern, escape="\\"), + ) + ) result = await db.execute(query) todos = result.scalars().all() diff --git a/backend/tests/test_todos.py b/backend/tests/test_todos.py index f798aa3..fc1f7f4 100644 --- a/backend/tests/test_todos.py +++ b/backend/tests/test_todos.py @@ -210,3 +210,50 @@ async def test_archive_nonexistent_todo(client: AsyncClient): response = await client.post("/api/todos/9999/archive") assert response.status_code == 404 assert response.json()["detail"] == "Todo not found" + + +@pytest.mark.asyncio +async def test_search_matches_title_and_description_case_insensitively(client: AsyncClient): + """Test searching todos by text in the title or description.""" + await client.post("/api/todos", json={"title": "Buy Milk"}) + await client.post( + "/api/todos", + json={"title": "Plan trip", "description": "Book flights and a MILK run"}, + ) + await client.post("/api/todos", json={"title": "Unrelated"}) + + response = await client.get("/api/todos?search=milk") + assert response.status_code == 200 + assert sorted(todo["title"] for todo in response.json()) == ["Buy Milk", "Plan trip"] + + +@pytest.mark.asyncio +async def test_search_without_matches_returns_empty_list(client: AsyncClient): + """Test that a search with no matches returns an empty list.""" + await client.post("/api/todos", json={"title": "Buy milk"}) + + response = await client.get("/api/todos?search=bread") + assert response.status_code == 200 + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_search_treats_wildcards_literally(client: AsyncClient): + """Test that % and _ in the search term are not interpreted as LIKE wildcards.""" + await client.post("/api/todos", json={"title": "Reach 100% coverage"}) + await client.post("/api/todos", json={"title": "Reach 100 points"}) + + response = await client.get("/api/todos?search=100%25") + assert [todo["title"] for todo in response.json()] == ["Reach 100% coverage"] + + +@pytest.mark.asyncio +async def test_search_combines_with_completed_filter(client: AsyncClient): + """Test combining search with the completed filter.""" + open_todo = await client.post("/api/todos", json={"title": "Write report"}) + done_todo = await client.post("/api/todos", json={"title": "Write changelog"}) + await client.patch(f"/api/todos/{done_todo.json()['id']}", json={"completed": True}) + + response = await client.get("/api/todos?search=write&completed=true") + assert [todo["id"] for todo in response.json()] == [done_todo.json()["id"]] + assert open_todo.json()["id"] not in [todo["id"] for todo in response.json()] diff --git a/docs/api-reference.md b/docs/api-reference.md index 8eb3759..43a72ec 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -30,9 +30,11 @@ Returns todos, newest first. Archived todos are hidden unless you ask for them. | --- | --- | --- | | `completed` | boolean | Return only completed or only open todos. | | `include_archived` | boolean | Include archived todos. Defaults to `false`. | +| `search` | string | Only return todos whose title or description contains the text, ignoring case. 1 to 100 characters. | ```bash curl "http://localhost:8000/api/todos?completed=false" +curl "http://localhost:8000/api/todos?search=milk" ``` ### Create a todo From 68e62140d0f626456329ae9b676395362dbac1cc Mon Sep 17 00:00:00 2001 From: Aleks Volochnev Date: Fri, 9 Oct 2026 21:18:24 +0200 Subject: [PATCH 2/2] Mention search in the list_todos docstring --- backend/app/routes/todos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routes/todos.py b/backend/app/routes/todos.py index 98e79b1..1649572 100644 --- a/backend/app/routes/todos.py +++ b/backend/app/routes/todos.py @@ -31,7 +31,7 @@ async def list_todos( ), db: AsyncSession = Depends(get_db), ) -> list[Todo]: - """Retrieve all todos, optionally filtered by completion status.""" + """Retrieve todos, optionally filtered by completion status or a search term.""" query = select(Todo).order_by(Todo.created_at.desc()) if completed is not None: