feat(todos): endpoints CRUD + postpone — 15 tests passent

Ajoute les 5 endpoints REST (list, create, update, delete, postpone),
enregistre le routeur sur /api/todos, et corrige l'isolation des sessions
de test via NullPool + dependency_overrides dans conftest.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 09:20:53 +02:00
parent 861f6497bb
commit a3704a2b27
3 changed files with 136 additions and 6 deletions
+110
View File
@@ -0,0 +1,110 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.todos import TodoItem
from app.schemas.todos import TodoCreate, TodoUpdate, PostponeRequest, TodoResponse
router = APIRouter()
@router.get("/", response_model=list[TodoResponse])
async def list_todos(
domain: str | None = None,
status: str | None = "pending",
priority: str | None = None,
tag: str | None = None,
due_after: str | None = None,
due_before: str | None = None,
limit: int = 200,
session: AsyncSession = Depends(get_session),
):
conditions = []
if domain:
conditions.append(TodoItem.domain == domain)
if status:
conditions.append(TodoItem.status == status)
if priority:
conditions.append(TodoItem.priority == priority)
if tag:
conditions.append(TodoItem.tags.contains([tag]))
if due_after:
conditions.append(TodoItem.due_date >= datetime.fromisoformat(due_after))
if due_before:
conditions.append(TodoItem.due_date <= datetime.fromisoformat(due_before))
stmt = select(TodoItem)
if conditions:
stmt = stmt.where(and_(*conditions))
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
@router.post("/", response_model=TodoResponse, status_code=201)
async def create_todo(
payload: TodoCreate,
session: AsyncSession = Depends(get_session),
):
item = TodoItem(**payload.model_dump())
session.add(item)
await session.commit()
await session.refresh(item)
return item
@router.patch("/{item_id}", response_model=TodoResponse)
async def update_todo(
item_id: uuid.UUID,
payload: TodoUpdate,
session: AsyncSession = Depends(get_session),
):
item = await session.get(TodoItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Tâche introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
item.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(item)
return item
@router.delete("/{item_id}", status_code=204)
async def delete_todo(
item_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
item = await session.get(TodoItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Tâche introuvable")
await session.delete(item)
await session.commit()
return Response(status_code=204)
@router.post("/{item_id}/postpone", response_model=TodoResponse)
async def postpone_todo(
item_id: uuid.UUID,
payload: PostponeRequest,
session: AsyncSession = Depends(get_session),
):
item = await session.get(TodoItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Tâche introuvable")
now = datetime.now(timezone.utc)
base = item.due_date if item.due_date else now
item.due_date = base + timedelta(days=payload.days)
item.postponed_count += 1
item.updated_at = now
await session.commit()
await session.refresh(item)
return item
+2
View File
@@ -3,6 +3,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.health import router as health_router
from app.api.media import router as media_router
from app.api.todos import router as todos_router
from app.core.config import settings
from app.data.seed import run_seed
@@ -25,3 +26,4 @@ app.add_middleware(
app.include_router(health_router, prefix="/api")
app.include_router(media_router, prefix="/api/media")
app.include_router(todos_router, prefix="/api/todos")
+24 -6
View File
@@ -1,16 +1,34 @@
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import NullPool
from app.main import app
from app.core.database import AsyncSessionLocal
from app.core import database
from app.core.config import settings
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
def make_test_engine():
"""Crée un engine sans pool pour éviter les conflits d'event loop entre tests."""
return create_async_engine(settings.database_url, poolclass=NullPool)
@pytest.fixture
async def db_session():
async with AsyncSessionLocal() as session:
engine = make_test_engine()
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()
@pytest.fixture
async def client(db_session: AsyncSession):
"""Client HTTP qui injecte la session de test dans l'app FastAPI."""
async def override_get_session():
yield db_session
app.dependency_overrides[database.get_session] = override_get_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()