Files
Johan LEROY c733ccfc62 refactor(backend): passe les tables data au singulier et clarifie alert_id
La convention de docs/architecture/40-data.md impose des noms de tables au
singulier, que les quatre tables d'authentification respectent déjà. Les six
tables data passent donc au singulier, avec leurs contraintes et leurs index.

La révision n'étant appliquée que sur des bases locales, elle est modifiée sur
place plutôt que doublée d'une migration de renommage.

alert_id désignait deux colonnes différentes : la clé métier text de l'API Mock
et la clé étrangère bigint de recommendation. La première devient
source_alert_id, la seconde pointe désormais vers alert.alert_id.
2026-09-16 08:41:48 +02:00

262 lines
7.9 KiB
Python

from collections.abc import AsyncIterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from sqlalchemy import insert, select, text
from sqlalchemy.engine import make_url
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
pytestmark = pytest.mark.integration
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@pytest.fixture
async def data_connection() -> AsyncIterator[AsyncConnection]:
url = make_url(get_settings().database_url)
if url.database != "enervision_test":
pytest.fail("Ces tests exigent DATABASE_URL vers enervision_test.")
engine = create_async_engine(url)
try:
async with engine.connect() as connection:
transaction = await connection.begin()
try:
yield connection
finally:
await transaction.rollback()
finally:
await engine.dispose()
@pytest.fixture
async def data_site(data_connection: AsyncConnection) -> str:
site_id = f"TEST-{uuid4()}"
await data_connection.execute(
insert(Site).values(site_id=site_id, site_name="Site de test", site_type="office")
)
return site_id
async def test_reading_is_a_time_hypertable_when_migrated(
data_connection: AsyncConnection,
) -> None:
query = text(
"SELECT column_name FROM timescaledb_information.dimensions "
"WHERE hypertable_schema = 'public' AND hypertable_name = 'reading'"
)
result = await data_connection.execute(query)
assert result.scalars().all() == ["timestamp"]
async def test_reading_preserves_null_and_zero_when_inserted(
data_connection: AsyncConnection, data_site: str
) -> None:
statement = insert(Reading).values(
site_id=data_site,
timestamp=MOMENT,
source="api_current",
consumption_kw=None,
consumption_kwh=0,
data_quality="partial",
null_reasons=["sensor_failure"],
raw_data={"consumption_kw": None},
imputed_values=None,
imputation_method=None,
)
await data_connection.execute(statement)
result = (
await data_connection.execute(
select(
Reading.consumption_kw,
Reading.consumption_kwh,
Reading.raw_data,
Reading.imputed_values,
).where(Reading.site_id == data_site)
)
).one()
assert tuple(result) == (None, 0, {"consumption_kw": None}, None)
@pytest.mark.parametrize("source", ["csv", "api_current", "api_history"])
async def test_duplicate_reading_is_rejected_when_key_matches(
data_connection: AsyncConnection, data_site: str, source: str
) -> None:
dataset_id = None
if source == "csv":
dataset_id = (
await data_connection.execute(
insert(Dataset.__table__)
.values(
dataset_name="Archive de test",
archive_sha256=uuid4().hex + uuid4().hex,
storage_uri="test://archive",
metadata={},
)
.returning(Dataset.dataset_id)
)
).scalar_one()
statement = insert(Reading).values(
site_id=data_site,
timestamp=MOMENT,
source=source,
dataset_id=dataset_id,
raw_data={},
)
await data_connection.execute(statement)
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(statement)
@pytest.mark.parametrize(
"changes",
[
{"source": "csv"},
{"source": "unknown"},
{"site_id": "UNKNOWN-SITE"},
{"data_quality": "unknown"},
{"imputed_values": {"consumption_kw": 12}},
{"imputation_method": "mean-v1"},
],
ids=[
"csv_sans_dataset",
"source_inconnue",
"site_absent",
"qualite_inconnue",
"imputation_sans_methode",
"methode_sans_imputation",
],
)
async def test_invalid_reading_is_rejected_when_constraints_fail(
data_connection: AsyncConnection, data_site: str, changes: dict[str, object]
) -> None:
values: dict[str, object] = {
"site_id": data_site,
"timestamp": MOMENT,
"source": "api_current",
"raw_data": {},
}
values.update(changes)
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(insert(Reading).values(**values))
async def test_prediction_requires_period_when_energy_is_predicted(
data_connection: AsyncConnection, data_site: str
) -> None:
statement = insert(Prediction).values(
site_id=data_site,
target_at=MOMENT,
target_metric="consumption_kwh",
predicted_value=12,
status="available",
model_reference="test-model/1",
)
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(statement)
async def test_unavailable_prediction_preserves_null_when_inserted(
data_connection: AsyncConnection, data_site: str
) -> None:
statement = (
insert(Prediction)
.values(
site_id=data_site,
target_at=MOMENT,
target_metric="consumption_kw",
status="insufficient_data",
failure_reason="Historique trop court",
model_reference="test-model/1",
)
.returning(Prediction.predicted_value)
)
value = (await data_connection.execute(statement)).scalar_one()
assert value is None
async def test_alert_rejects_prediction_when_site_differs(
data_connection: AsyncConnection, data_site: str
) -> None:
other_site = f"TEST-{uuid4()}"
await data_connection.execute(
insert(Site).values(site_id=other_site, site_name="Autre site", site_type="office")
)
prediction_id = (
await data_connection.execute(
insert(Prediction)
.values(
site_id=data_site,
target_at=MOMENT,
target_metric="consumption_kw",
predicted_value=12,
status="available",
model_reference="test-model/1",
)
.returning(Prediction.prediction_id)
)
).scalar_one()
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(
insert(Alert).values(
source_alert_id=str(uuid4()),
site_id=other_site,
source="enervision",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
prediction_id=prediction_id,
raw_data={},
)
)
async def test_recommendation_is_unique_when_alert_and_rule_match(
data_connection: AsyncConnection, data_site: str
) -> None:
alert_id = (
await data_connection.execute(
insert(Alert)
.values(
source_alert_id=str(uuid4()),
site_id=data_site,
source="api_mock",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
raw_data={},
)
.returning(Alert.alert_id)
)
).scalar_one()
statement = insert(Recommendation).values(
alert_id=alert_id,
action="Vérifier la consommation",
explanation="Pic détecté",
rule_reference="spike-v1",
)
await data_connection.execute(statement)
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(statement)