diff --git a/.claude/skills/project-conventions/SKILL.md b/.claude/skills/project-conventions/SKILL.md new file mode 100644 index 0000000..3d3b98d --- /dev/null +++ b/.claude/skills/project-conventions/SKILL.md @@ -0,0 +1,68 @@ +--- +name: project-conventions +description: Enforces Temis-RiskControl project conventions for backend architecture, file placement, naming, and stack usage. Invoke before adding features, new files, or modifying existing layers. +--- + +# Temis-RiskControl — Project Conventions + +When working in this project, always follow the rules below. Do not deviate unless the user explicitly asks. + +--- + +## Stack + +- **Backend**: Python 3.12 + FastAPI + Uvicorn +- **Config**: Pydantic `BaseSettings` — never hardcode values, always read from environment +- **Frontend**: Not yet implemented — do not scaffold it unless asked +- **Agent**: Not yet implemented — do not scaffold it unless asked + +--- + +## Backend Folder Structure + +All backend code lives inside `backend/app/`. Respect the layered architecture: + +| Folder | What belongs here | +|---|---| +| `api/` | FastAPI route handlers and the router aggregator (`router.py`) | +| `core/` | App-wide config, settings, startup logic | +| `database/` | DB connection setup, session factories | +| `entities/` | Domain/business entities (pure Python classes, no DB coupling) | +| `models/` | Pydantic request/response schemas | +| `repositories/` | Data access — all queries go here, never in routes | +| `infra/` | External service clients (email, storage, third-party APIs) | + +### Rules + +- Never put business logic directly in `api/` route handlers — delegate to a service or repository +- Never import from `api/` inside `repositories/`, `entities/`, or `models/` — dependencies flow downward only +- New endpoints must be registered in `backend/app/api/router.py`, not mounted directly in `main.py` +- All API routes must be prefixed with `/api/` + +--- + +## Naming Conventions + +- **Files**: `snake_case.py` +- **Classes**: `PascalCase` +- **Functions and variables**: `snake_case` +- **Pydantic models**: suffix with `Request`, `Response`, or `Schema` (e.g., `CreateUserRequest`, `UserResponse`) +- **Repository classes**: suffix with `Repository` (e.g., `UserRepository`) +- **Entity classes**: no suffix, plain domain name (e.g., `User`, `RiskEvent`) + +--- + +## Environment Variables + +- Never hardcode secrets or config values +- All variables must have a corresponding entry in `backend/.env.example` +- Read them through the Pydantic `Settings` class in `backend/app/core/config.py` + + +## What NOT to do + +- Do not create files outside their designated layer folder +- Do not add dependencies to `requirements.txt` without a clear reason — ask the user first +- Do not implement frontend or agent features unless explicitly asked +- Do not skip `.env.example` updates when adding new environment variables +- Do not add endpoints directly in `main.py` — always go through `router.py` diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 55f624b..1326aa0 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,8 @@ from fastapi import APIRouter from app.api.health import router as health_router +from app.api.usuarios import router as usuarios_router api_router = APIRouter(prefix="/api") api_router.include_router(health_router) +api_router.include_router(usuarios_router) diff --git a/backend/app/api/usuarios.py b/backend/app/api/usuarios.py new file mode 100644 index 0000000..6670024 --- /dev/null +++ b/backend/app/api/usuarios.py @@ -0,0 +1,57 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.usuario import CreateUsuarioRequest, UpdateUsuarioRequest, UsuarioResponse +from app.repositories.usuario_repository import UsuarioRepository + +router = APIRouter(prefix="/usuarios", tags=["usuarios"]) + + +@router.post("", response_model=UsuarioResponse, status_code=status.HTTP_201_CREATED) +async def create_usuario(body: CreateUsuarioRequest, db: AsyncSession = Depends(get_db)) -> UsuarioResponse: + repo = UsuarioRepository(db) + try: + return await repo.create(email=body.email, telefono=body.telefono, status=body.status) + except IntegrityError: + raise HTTPException(status_code=409, detail="Email already registered") + + +@router.get("", response_model=list[UsuarioResponse]) +async def list_usuarios(db: AsyncSession = Depends(get_db)) -> list[UsuarioResponse]: + repo = UsuarioRepository(db) + return await repo.get_all() + + +@router.get("/{usuario_id}", response_model=UsuarioResponse) +async def get_usuario(usuario_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> UsuarioResponse: + repo = UsuarioRepository(db) + usuario = await repo.get_by_id(usuario_id) + if not usuario: + raise HTTPException(status_code=404, detail="Usuario not found") + return usuario + + +@router.patch("/{usuario_id}", response_model=UsuarioResponse) +async def update_usuario( + usuario_id: uuid.UUID, + body: UpdateUsuarioRequest, + db: AsyncSession = Depends(get_db), +) -> UsuarioResponse: + repo = UsuarioRepository(db) + usuario = await repo.get_by_id(usuario_id) + if not usuario: + raise HTTPException(status_code=404, detail="Usuario not found") + return await repo.update(usuario, email=body.email, telefono=body.telefono, status=body.status) + + +@router.delete("/{usuario_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_usuario(usuario_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = UsuarioRepository(db) + usuario = await repo.get_by_id(usuario_id) + if not usuario: + raise HTTPException(status_code=404, detail="Usuario not found") + await repo.delete(usuario) diff --git a/backend/app/database/base.py b/backend/app/database/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/database/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/database/session.py b/backend/app/database/session.py new file mode 100644 index 0000000..10f0a44 --- /dev/null +++ b/backend/app/database/session.py @@ -0,0 +1,14 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +engine = create_async_engine(settings.database_url, echo=settings.debug) + +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/entities/usuario.py b/backend/app/entities/usuario.py new file mode 100644 index 0000000..a967fd4 --- /dev/null +++ b/backend/app/entities/usuario.py @@ -0,0 +1,18 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class Usuario(Base): + __tablename__ = "usuario" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String, unique=True, nullable=False) + telefono: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[str] = mapped_column(String, default="active") + last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/models/usuario.py b/backend/app/models/usuario.py new file mode 100644 index 0000000..647d099 --- /dev/null +++ b/backend/app/models/usuario.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, EmailStr + + +class CreateUsuarioRequest(BaseModel): + email: EmailStr + telefono: str | None = None + status: str = "active" + + +class UpdateUsuarioRequest(BaseModel): + email: EmailStr | None = None + telefono: str | None = None + status: str | None = None + + +class UsuarioResponse(BaseModel): + id: uuid.UUID + email: str + telefono: str | None + status: str + last_login: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/repositories/usuario_repository.py b/backend/app/repositories/usuario_repository.py new file mode 100644 index 0000000..253942a --- /dev/null +++ b/backend/app/repositories/usuario_repository.py @@ -0,0 +1,38 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.usuario import Usuario + + +class UsuarioRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, email: str, telefono: str | None, status: str) -> Usuario: + usuario = Usuario(email=email, telefono=telefono, status=status) + self.db.add(usuario) + await self.db.commit() + await self.db.refresh(usuario) + return usuario + + async def get_by_id(self, usuario_id: uuid.UUID) -> Usuario | None: + result = await self.db.execute(select(Usuario).where(Usuario.id == usuario_id)) + return result.scalar_one_or_none() + + async def get_all(self) -> list[Usuario]: + result = await self.db.execute(select(Usuario)) + return list(result.scalars().all()) + + async def update(self, usuario: Usuario, **fields: object) -> Usuario: + for key, value in fields.items(): + if value is not None: + setattr(usuario, key, value) + await self.db.commit() + await self.db.refresh(usuario) + return usuario + + async def delete(self, usuario: Usuario) -> None: + await self.db.delete(usuario) + await self.db.commit() diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..a029478 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,36 @@ +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.database.base import Base +from app.database.session import get_db +from app.main import app + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest_asyncio.fixture +async def db_session() -> AsyncSession: + engine = create_async_engine(TEST_DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + AsyncTestSession = async_sessionmaker(engine, expire_on_commit=False) + async with AsyncTestSession() as session: + yield session + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncClient: + async def override_get_db() -> AsyncSession: + yield db_session + + app.dependency_overrides[get_db] = override_get_db + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + app.dependency_overrides.clear() diff --git a/backend/tests/test_usuarios.py b/backend/tests/test_usuarios.py new file mode 100644 index 0000000..1085807 --- /dev/null +++ b/backend/tests/test_usuarios.py @@ -0,0 +1,85 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_create_usuario(client: AsyncClient) -> None: + response = await client.post("/api/usuarios", json={"email": "juan@example.com", "telefono": "555-1234"}) + assert response.status_code == 201 + data = response.json() + assert data["email"] == "juan@example.com" + assert data["telefono"] == "555-1234" + assert data["status"] == "active" + assert "id" in data + assert "created_at" in data + + +@pytest.mark.asyncio +async def test_create_usuario_duplicate_email(client: AsyncClient) -> None: + await client.post("/api/usuarios", json={"email": "dup@example.com"}) + response = await client.post("/api/usuarios", json={"email": "dup@example.com"}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_get_usuario(client: AsyncClient) -> None: + create_resp = await client.post("/api/usuarios", json={"email": "get@example.com"}) + user_id = create_resp.json()["id"] + + response = await client.get(f"/api/usuarios/{user_id}") + assert response.status_code == 200 + assert response.json()["email"] == "get@example.com" + + +@pytest.mark.asyncio +async def test_get_usuario_not_found(client: AsyncClient) -> None: + response = await client.get(f"/api/usuarios/{uuid.uuid4()}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_usuarios(client: AsyncClient) -> None: + await client.post("/api/usuarios", json={"email": "a@example.com"}) + await client.post("/api/usuarios", json={"email": "b@example.com"}) + + response = await client.get("/api/usuarios") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +@pytest.mark.asyncio +async def test_update_usuario(client: AsyncClient) -> None: + create_resp = await client.post("/api/usuarios", json={"email": "upd@example.com", "telefono": "111"}) + user_id = create_resp.json()["id"] + + response = await client.patch(f"/api/usuarios/{user_id}", json={"status": "inactive", "telefono": "999"}) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "inactive" + assert data["telefono"] == "999" + + +@pytest.mark.asyncio +async def test_update_usuario_not_found(client: AsyncClient) -> None: + response = await client.patch(f"/api/usuarios/{uuid.uuid4()}", json={"status": "inactive"}) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_usuario(client: AsyncClient) -> None: + create_resp = await client.post("/api/usuarios", json={"email": "del@example.com"}) + user_id = create_resp.json()["id"] + + response = await client.delete(f"/api/usuarios/{user_id}") + assert response.status_code == 204 + + get_resp = await client.get(f"/api/usuarios/{user_id}") + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_usuario_not_found(client: AsyncClient) -> None: + response = await client.delete(f"/api/usuarios/{uuid.uuid4()}") + assert response.status_code == 404