From a3704a2b2725393899a0cc6d078938b8174346bf Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sun, 24 May 2026 09:20:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(todos):=20endpoints=20CRUD=20+=20postpone?= =?UTF-8?q?=20=E2=80=94=2015=20tests=20passent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/todos.py | 110 ++++++++++++++++++++++++++++++++++++++ backend/app/main.py | 2 + backend/tests/conftest.py | 30 ++++++++--- 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 backend/app/api/todos.py diff --git a/backend/app/api/todos.py b/backend/app/api/todos.py new file mode 100644 index 0000000..52fda31 --- /dev/null +++ b/backend/app/api/todos.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index a958fe4..e2a4eb5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index da5785f..a33d97b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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()