Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions backend/app/routes/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,20 +13,41 @@
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."""
"""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:
query = query.where(Todo.completed == completed)

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()
Expand Down
47 changes: 47 additions & 0 deletions backend/tests/test_todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
2 changes: 2 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading