diff --git a/backend/app/api/cuentas.py b/backend/app/api/cuentas.py new file mode 100644 index 0000000..2e66178 --- /dev/null +++ b/backend/app/api/cuentas.py @@ -0,0 +1,61 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.cuenta import CreateCuentaRequest, CuentaResponse, UpdateCuentaRequest +from app.repositories.cuenta_repository import CuentaRepository + +router = APIRouter(prefix="/cuentas", tags=["cuentas"]) + + +@router.post("", response_model=CuentaResponse, status_code=status.HTTP_201_CREATED) +async def create_cuenta(body: CreateCuentaRequest, db: AsyncSession = Depends(get_db)) -> CuentaResponse: + repo = CuentaRepository(db) + return await repo.create( + user_id=body.user_id, + balance=body.balance, + currency=body.currency, + status=body.status, + ) + + +@router.get("", response_model=list[CuentaResponse]) +async def list_cuentas( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[CuentaResponse]: + repo = CuentaRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{cuenta_id}", response_model=CuentaResponse) +async def get_cuenta(cuenta_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> CuentaResponse: + repo = CuentaRepository(db) + cuenta = await repo.get_by_id(cuenta_id) + if not cuenta: + raise HTTPException(status_code=404, detail="Cuenta not found") + return cuenta + + +@router.patch("/{cuenta_id}", response_model=CuentaResponse) +async def update_cuenta( + cuenta_id: uuid.UUID, + body: UpdateCuentaRequest, + db: AsyncSession = Depends(get_db), +) -> CuentaResponse: + repo = CuentaRepository(db) + cuenta = await repo.get_by_id(cuenta_id) + if not cuenta: + raise HTTPException(status_code=404, detail="Cuenta not found") + return await repo.update(cuenta, balance=body.balance, currency=body.currency, status=body.status) + + +@router.delete("/{cuenta_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_cuenta(cuenta_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = CuentaRepository(db) + cuenta = await repo.get_by_id(cuenta_id) + if not cuenta: + raise HTTPException(status_code=404, detail="Cuenta not found") + await repo.delete(cuenta) diff --git a/backend/app/api/dispositivos.py b/backend/app/api/dispositivos.py new file mode 100644 index 0000000..a0ae39e --- /dev/null +++ b/backend/app/api/dispositivos.py @@ -0,0 +1,72 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.models.dispositivo import CreateDispositivoRequest, DispositivoResponse, UpdateDispositivoRequest +from app.repositories.dispositivo_repository import DispositivoRepository + +router = APIRouter(prefix="/dispositivos", tags=["dispositivos"]) + + +@router.post("", response_model=DispositivoResponse, status_code=status.HTTP_201_CREATED) +async def create_dispositivo( + body: CreateDispositivoRequest, db: AsyncSession = Depends(get_db) +) -> DispositivoResponse: + repo = DispositivoRepository(db) + return await repo.create(user_id=body.user_id, fingerprint=body.fingerprint, trusted=body.trusted) + + +@router.get("", response_model=list[DispositivoResponse]) +async def list_dispositivos( + user_id: uuid.UUID | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> list[DispositivoResponse]: + repo = DispositivoRepository(db) + return await repo.get_all(user_id=user_id) + + +@router.get("/{dispositivo_id}", response_model=DispositivoResponse) +async def get_dispositivo(dispositivo_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> DispositivoResponse: + repo = DispositivoRepository(db) + dispositivo = await repo.get_by_id(dispositivo_id) + if not dispositivo: + raise HTTPException(status_code=404, detail="Dispositivo not found") + return dispositivo + + +@router.patch("/{dispositivo_id}", response_model=DispositivoResponse) +async def update_dispositivo( + dispositivo_id: uuid.UUID, + body: UpdateDispositivoRequest, + db: AsyncSession = Depends(get_db), +) -> DispositivoResponse: + repo = DispositivoRepository(db) + dispositivo = await repo.get_by_id(dispositivo_id) + if not dispositivo: + raise HTTPException(status_code=404, detail="Dispositivo not found") + return await repo.update( + dispositivo, + fingerprint=body.fingerprint, + trusted=body.trusted, + last_seen=body.last_seen, + ) + + +@router.post("/{dispositivo_id}/touch", response_model=DispositivoResponse) +async def touch_dispositivo(dispositivo_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> DispositivoResponse: + repo = DispositivoRepository(db) + dispositivo = await repo.get_by_id(dispositivo_id) + if not dispositivo: + raise HTTPException(status_code=404, detail="Dispositivo not found") + return await repo.touch(dispositivo) + + +@router.delete("/{dispositivo_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_dispositivo(dispositivo_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None: + repo = DispositivoRepository(db) + dispositivo = await repo.get_by_id(dispositivo_id) + if not dispositivo: + raise HTTPException(status_code=404, detail="Dispositivo not found") + await repo.delete(dispositivo) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 1326aa0..eca6b13 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,8 +1,12 @@ from fastapi import APIRouter +from app.api.cuentas import router as cuentas_router +from app.api.dispositivos import router as dispositivos_router 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) +api_router.include_router(cuentas_router) +api_router.include_router(dispositivos_router) diff --git a/backend/app/entities/cuenta.py b/backend/app/entities/cuenta.py new file mode 100644 index 0000000..656b990 --- /dev/null +++ b/backend/app/entities/cuenta.py @@ -0,0 +1,17 @@ +import uuid +from decimal import Decimal + +from sqlalchemy import ForeignKey, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class Cuenta(Base): + __tablename__ = "cuenta" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("usuario.id", ondelete="CASCADE"), nullable=False) + balance: Mapped[Decimal] = mapped_column(Numeric(precision=18, scale=2), default=Decimal("0.00")) + currency: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, default="active") diff --git a/backend/app/entities/dispositivo.py b/backend/app/entities/dispositivo.py new file mode 100644 index 0000000..3bb74d3 --- /dev/null +++ b/backend/app/entities/dispositivo.py @@ -0,0 +1,18 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.base import Base + + +class Dispositivo(Base): + __tablename__ = "dispositivo" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("usuario.id", ondelete="CASCADE"), nullable=False) + fingerprint: Mapped[str] = mapped_column(String, nullable=False) + trusted: Mapped[bool] = mapped_column(Boolean, default=False) + first_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/models/cuenta.py b/backend/app/models/cuenta.py new file mode 100644 index 0000000..b6d87e8 --- /dev/null +++ b/backend/app/models/cuenta.py @@ -0,0 +1,27 @@ +import uuid +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class CreateCuentaRequest(BaseModel): + user_id: uuid.UUID + balance: Decimal = Field(default=Decimal("0.00"), ge=0) + currency: str + status: str = "active" + + +class UpdateCuentaRequest(BaseModel): + balance: Decimal | None = Field(default=None, ge=0) + currency: str | None = None + status: str | None = None + + +class CuentaResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + balance: Decimal + currency: str + status: str + + model_config = {"from_attributes": True} diff --git a/backend/app/models/dispositivo.py b/backend/app/models/dispositivo.py new file mode 100644 index 0000000..6eb7273 --- /dev/null +++ b/backend/app/models/dispositivo.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel + + +class CreateDispositivoRequest(BaseModel): + user_id: uuid.UUID + fingerprint: str + trusted: bool = False + + +class UpdateDispositivoRequest(BaseModel): + fingerprint: str | None = None + trusted: bool | None = None + last_seen: datetime | None = None + + +class DispositivoResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + fingerprint: str + trusted: bool + first_seen: datetime + last_seen: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/repositories/cuenta_repository.py b/backend/app/repositories/cuenta_repository.py new file mode 100644 index 0000000..ba4167f --- /dev/null +++ b/backend/app/repositories/cuenta_repository.py @@ -0,0 +1,42 @@ +import uuid +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.cuenta import Cuenta + + +class CuentaRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, balance: Decimal, currency: str, status: str) -> Cuenta: + cuenta = Cuenta(user_id=user_id, balance=balance, currency=currency, status=status) + self.db.add(cuenta) + await self.db.commit() + await self.db.refresh(cuenta) + return cuenta + + async def get_by_id(self, cuenta_id: uuid.UUID) -> Cuenta | None: + result = await self.db.execute(select(Cuenta).where(Cuenta.id == cuenta_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[Cuenta]: + query = select(Cuenta) + if user_id is not None: + query = query.where(Cuenta.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def update(self, cuenta: Cuenta, **fields: object) -> Cuenta: + for key, value in fields.items(): + if value is not None: + setattr(cuenta, key, value) + await self.db.commit() + await self.db.refresh(cuenta) + return cuenta + + async def delete(self, cuenta: Cuenta) -> None: + await self.db.delete(cuenta) + await self.db.commit() diff --git a/backend/app/repositories/dispositivo_repository.py b/backend/app/repositories/dispositivo_repository.py new file mode 100644 index 0000000..de1d88b --- /dev/null +++ b/backend/app/repositories/dispositivo_repository.py @@ -0,0 +1,49 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.entities.dispositivo import Dispositivo + + +class DispositivoRepository: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create(self, user_id: uuid.UUID, fingerprint: str, trusted: bool) -> Dispositivo: + dispositivo = Dispositivo(user_id=user_id, fingerprint=fingerprint, trusted=trusted) + self.db.add(dispositivo) + await self.db.commit() + await self.db.refresh(dispositivo) + return dispositivo + + async def get_by_id(self, dispositivo_id: uuid.UUID) -> Dispositivo | None: + result = await self.db.execute(select(Dispositivo).where(Dispositivo.id == dispositivo_id)) + return result.scalar_one_or_none() + + async def get_all(self, user_id: uuid.UUID | None = None) -> list[Dispositivo]: + query = select(Dispositivo) + if user_id is not None: + query = query.where(Dispositivo.user_id == user_id) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def update(self, dispositivo: Dispositivo, **fields: object) -> Dispositivo: + for key, value in fields.items(): + if value is not None: + setattr(dispositivo, key, value) + await self.db.commit() + await self.db.refresh(dispositivo) + return dispositivo + + async def touch(self, dispositivo: Dispositivo) -> Dispositivo: + """Update last_seen to now.""" + dispositivo.last_seen = datetime.now(UTC) + await self.db.commit() + await self.db.refresh(dispositivo) + return dispositivo + + async def delete(self, dispositivo: Dispositivo) -> None: + await self.db.delete(dispositivo) + await self.db.commit() diff --git a/backend/tests/test_cuentas.py b/backend/tests/test_cuentas.py new file mode 100644 index 0000000..d1b38c0 --- /dev/null +++ b/backend/tests/test_cuentas.py @@ -0,0 +1,114 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_cuenta(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/cuentas", + json={"user_id": user_id, "balance": "1500.00", "currency": "MXN"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["user_id"] == user_id + assert data["balance"] == "1500.00" + assert data["currency"] == "MXN" + assert data["status"] == "active" + assert "id" in data + + +@pytest.mark.asyncio +async def test_get_cuenta(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/cuentas", + json={"user_id": user_id, "balance": "200.00", "currency": "USD"}, + ) + cuenta_id = create_resp.json()["id"] + + response = await client.get(f"/api/cuentas/{cuenta_id}") + assert response.status_code == 200 + assert response.json()["currency"] == "USD" + + +@pytest.mark.asyncio +async def test_get_cuenta_not_found(client: AsyncClient) -> None: + response = await client.get(f"/api/cuentas/{uuid.uuid4()}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_cuentas(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + await client.post("/api/cuentas", json={"user_id": user_id, "balance": "100.00", "currency": "MXN"}) + await client.post("/api/cuentas", json={"user_id": user_id, "balance": "200.00", "currency": "USD"}) + + response = await client.get("/api/cuentas") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +@pytest.mark.asyncio +async def test_list_cuentas_filtered_by_user(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/cuentas", json={"user_id": user_a, "balance": "100.00", "currency": "MXN"}) + await client.post("/api/cuentas", json={"user_id": user_b, "balance": "200.00", "currency": "USD"}) + + response = await client.get(f"/api/cuentas?user_id={user_a}") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["user_id"] == user_a + + +@pytest.mark.asyncio +async def test_update_cuenta(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/cuentas", + json={"user_id": user_id, "balance": "500.00", "currency": "MXN"}, + ) + cuenta_id = create_resp.json()["id"] + + response = await client.patch(f"/api/cuentas/{cuenta_id}", json={"balance": "999.99", "status": "frozen"}) + assert response.status_code == 200 + data = response.json() + assert data["balance"] == "999.99" + assert data["status"] == "frozen" + + +@pytest.mark.asyncio +async def test_update_cuenta_not_found(client: AsyncClient) -> None: + response = await client.patch(f"/api/cuentas/{uuid.uuid4()}", json={"status": "frozen"}) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_cuenta(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/cuentas", + json={"user_id": user_id, "balance": "0.00", "currency": "MXN"}, + ) + cuenta_id = create_resp.json()["id"] + + response = await client.delete(f"/api/cuentas/{cuenta_id}") + assert response.status_code == 204 + + get_resp = await client.get(f"/api/cuentas/{cuenta_id}") + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_cuenta_not_found(client: AsyncClient) -> None: + response = await client.delete(f"/api/cuentas/{uuid.uuid4()}") + assert response.status_code == 404 diff --git a/backend/tests/test_dispositivos.py b/backend/tests/test_dispositivos.py new file mode 100644 index 0000000..7adac29 --- /dev/null +++ b/backend/tests/test_dispositivos.py @@ -0,0 +1,132 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +async def _create_usuario(client: AsyncClient) -> str: + resp = await client.post("/api/usuarios", json={"email": f"{uuid.uuid4()}@example.com"}) + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_dispositivo(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + response = await client.post( + "/api/dispositivos", + json={"user_id": user_id, "fingerprint": "abc123", "trusted": False}, + ) + assert response.status_code == 201 + data = response.json() + assert data["user_id"] == user_id + assert data["fingerprint"] == "abc123" + assert data["trusted"] is False + assert "first_seen" in data + assert "last_seen" in data + + +@pytest.mark.asyncio +async def test_get_dispositivo(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/dispositivos", + json={"user_id": user_id, "fingerprint": "fp-001"}, + ) + dispositivo_id = create_resp.json()["id"] + + response = await client.get(f"/api/dispositivos/{dispositivo_id}") + assert response.status_code == 200 + assert response.json()["fingerprint"] == "fp-001" + + +@pytest.mark.asyncio +async def test_get_dispositivo_not_found(client: AsyncClient) -> None: + response = await client.get(f"/api/dispositivos/{uuid.uuid4()}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_list_dispositivos(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + await client.post("/api/dispositivos", json={"user_id": user_id, "fingerprint": "fp-a"}) + await client.post("/api/dispositivos", json={"user_id": user_id, "fingerprint": "fp-b"}) + + response = await client.get("/api/dispositivos") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +@pytest.mark.asyncio +async def test_list_dispositivos_filtered_by_user(client: AsyncClient) -> None: + user_a = await _create_usuario(client) + user_b = await _create_usuario(client) + await client.post("/api/dispositivos", json={"user_id": user_a, "fingerprint": "fp-a"}) + await client.post("/api/dispositivos", json={"user_id": user_b, "fingerprint": "fp-b"}) + + response = await client.get(f"/api/dispositivos?user_id={user_a}") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["user_id"] == user_a + + +@pytest.mark.asyncio +async def test_update_dispositivo(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/dispositivos", + json={"user_id": user_id, "fingerprint": "fp-old", "trusted": False}, + ) + dispositivo_id = create_resp.json()["id"] + + response = await client.patch( + f"/api/dispositivos/{dispositivo_id}", + json={"fingerprint": "fp-new", "trusted": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["fingerprint"] == "fp-new" + assert data["trusted"] is True + + +@pytest.mark.asyncio +async def test_update_dispositivo_not_found(client: AsyncClient) -> None: + response = await client.patch(f"/api/dispositivos/{uuid.uuid4()}", json={"trusted": True}) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_touch_dispositivo(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/dispositivos", + json={"user_id": user_id, "fingerprint": "fp-touch"}, + ) + dispositivo_id = create_resp.json()["id"] + original_last_seen = create_resp.json()["last_seen"] + + response = await client.post(f"/api/dispositivos/{dispositivo_id}/touch") + assert response.status_code == 200 + assert response.json()["last_seen"] >= original_last_seen + + +@pytest.mark.asyncio +async def test_delete_dispositivo(client: AsyncClient) -> None: + user_id = await _create_usuario(client) + create_resp = await client.post( + "/api/dispositivos", + json={"user_id": user_id, "fingerprint": "fp-del"}, + ) + dispositivo_id = create_resp.json()["id"] + + response = await client.delete(f"/api/dispositivos/{dispositivo_id}") + assert response.status_code == 204 + + get_resp = await client.get(f"/api/dispositivos/{dispositivo_id}") + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_dispositivo_not_found(client: AsyncClient) -> None: + response = await client.delete(f"/api/dispositivos/{uuid.uuid4()}") + assert response.status_code == 404