Spaces:
Runtime error
Runtime error
| """ | |
| Archivo: test_db.py | |
| Fecha de modificación: 03/06/2026 | |
| Autor: Equipo AgroVisión | |
| Descripción: | |
| Pruebas de integración de la capa de persistencia (Fase 2) contra el Supabase del | |
| usuario (PostgreSQL + PostGIS). Verifican que las migraciones crean el esquema, que | |
| el CRUD de parcelas hace round-trip, que el upsert de NDVI es idempotente y que la | |
| memoria de chat se recupera ordenada. Se omiten si `DATABASE_URL` no está configurado. | |
| Ejecución: | |
| uv run python -m pytest tests/integration/test_db.py -v | |
| Nota: estos tests escriben en la BD real usando un `user_id` aleatorio y limpian sus | |
| propias filas al final (no contaminan datos existentes). | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import contextlib | |
| import os | |
| import uuid | |
| import pytest | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| pytestmark = pytest.mark.skipif( | |
| not os.getenv("DATABASE_URL"), reason="DATABASE_URL no configurado (Supabase)" | |
| ) | |
| from backend.db import repositories as repo # noqa: E402 | |
| from backend.db.migrate import apply_migrations # noqa: E402 | |
| from backend.db.session import get_engine, get_sessionmaker # noqa: E402 | |
| async def _engine_scope(): | |
| """ | |
| Provee un sessionmaker con engine fresco por test y lo desecha en el mismo loop. | |
| Necesario porque cada test usa su propio `asyncio.run()` (event loop nuevo) y el | |
| engine está memoizado; sin esto, el pool quedaría atado a un loop ya cerrado. | |
| """ | |
| get_engine.cache_clear() | |
| get_sessionmaker.cache_clear() | |
| try: | |
| yield get_sessionmaker() | |
| finally: | |
| await get_engine().dispose() | |
| get_engine.cache_clear() | |
| get_sessionmaker.cache_clear() | |
| _SQUARE = { | |
| "type": "Polygon", | |
| "coordinates": [ | |
| [[-58.0, -34.0], [-58.0, -34.1], [-57.9, -34.1], [-57.9, -34.0], [-58.0, -34.0]] | |
| ], | |
| } | |
| def _migrate() -> None: | |
| """Aplica las migraciones una vez antes del módulo (idempotente).""" | |
| asyncio.run(apply_migrations()) | |
| def test_migraciones_crean_las_tablas() -> None: | |
| """Tras migrar, las 4 tablas del diseño existen en el esquema public.""" | |
| async def _run() -> None: | |
| from sqlalchemy import text | |
| async with _engine_scope() as sm, sm() as session: | |
| rows = await session.execute( | |
| text( | |
| "select table_name from information_schema.tables " | |
| "where table_schema='public' and table_name = any(:names)" | |
| ), | |
| {"names": ["fields", "ndvi_timeseries", "plant_counts", "chat_messages"]}, | |
| ) | |
| present = {r[0] for r in rows} | |
| assert present == {"fields", "ndvi_timeseries", "plant_counts", "chat_messages"} | |
| asyncio.run(_run()) | |
| def test_field_crud_roundtrip() -> None: | |
| """Crear → obtener → listar → borrar una parcela funciona de extremo a extremo.""" | |
| user_id = str(uuid.uuid4()) | |
| async def _run() -> None: | |
| async with _engine_scope() as sm, sm() as session: | |
| try: | |
| field = await repo.create_field( | |
| session, name="Lote Test", geojson=_SQUARE, user_id=user_id | |
| ) | |
| assert field.name == "Lote Test" | |
| fetched = await repo.get_field(session, field.id) | |
| assert fetched is not None | |
| listed = await repo.list_fields(session, user_id=user_id) | |
| assert any(f.id == field.id for f in listed) | |
| assert field.area_ha and field.area_ha > 0 # ST_Area calculada | |
| finally: | |
| await repo.delete_fields_for_user(session, user_id) | |
| asyncio.run(_run()) | |
| def test_ndvi_upsert_idempotente() -> None: | |
| """Reinsertar los mismos puntos NDVI no duplica filas (UNIQUE field_id, date).""" | |
| user_id = str(uuid.uuid4()) | |
| points = [ | |
| {"date": "2026-03-01", "mean_ndvi": 0.66, "cloud_cover": 8}, | |
| {"date": "2026-04-01", "mean_ndvi": 0.72, "cloud_cover": 5}, | |
| ] | |
| async def _run() -> None: | |
| async with _engine_scope() as sm, sm() as session: | |
| try: | |
| field = await repo.create_field( | |
| session, name="Lote NDVI", geojson=_SQUARE, user_id=user_id | |
| ) | |
| await repo.upsert_ndvi_points(session, field.id, points) | |
| await repo.upsert_ndvi_points(session, field.id, points) # idempotente | |
| series = await repo.get_ndvi_series(session, field.id) | |
| assert len(series) == 2 | |
| assert [p["date"].isoformat() for p in series] == ["2026-03-01", "2026-04-01"] | |
| finally: | |
| await repo.delete_fields_for_user(session, user_id) | |
| asyncio.run(_run()) | |
| def test_chat_history_ordenado() -> None: | |
| """La memoria conversacional se recupera en orden cronológico por sesión.""" | |
| user_id = str(uuid.uuid4()) | |
| session_id = f"sess-{uuid.uuid4()}" | |
| async def _run() -> None: | |
| async with _engine_scope() as sm, sm() as session: | |
| try: | |
| await repo.save_chat_message( | |
| session, session_id=session_id, role="user", content="hola", user_id=user_id | |
| ) | |
| await repo.save_chat_message( | |
| session, | |
| session_id=session_id, | |
| role="assistant", | |
| content="qué tal", | |
| user_id=user_id, | |
| ) | |
| history = await repo.get_chat_history(session, session_id) | |
| assert [m.role for m in history] == ["user", "assistant"] | |
| finally: | |
| await repo.delete_chat_for_session(session, session_id) | |
| asyncio.run(_run()) | |