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:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user