test(apps): verifie les contraintes du schema data
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
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_readings_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 = 'readings'"
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
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(
|
||||||
|
alert_id=str(uuid4()),
|
||||||
|
site_id=data_site,
|
||||||
|
source="api_mock",
|
||||||
|
timestamp=MOMENT,
|
||||||
|
type="spike",
|
||||||
|
severity="high",
|
||||||
|
message="Test",
|
||||||
|
raw_data={},
|
||||||
|
)
|
||||||
|
.returning(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)
|
||||||
Reference in New Issue
Block a user