Deux causes distinctes, toutes deux invisibles sans base. `creer_lecture` ne posait pas `consumption_kwh` : l'override etait ignore en silence, la colonne restait nulle, et la jointure de derive, qui ecarte les lectures sans mesure, ne trouvait donc aucune paire. Le helper accepte desormais ce champ, nul par defaut, ce qui ne change rien pour les dix fichiers qui l'utilisent deja. `test_the_operator_rank_opens_nothing_more_than_the_reader_rank` figeait l'egalite des deux rangs en annoncant, dans son propre commentaire, qu'il devait sonner « le jour ou une route d'operateur arrive ». Ce jour est arrive avec `GET /monitoring/drift`. Le test compare maintenant chaque route a ce que `ROLE_MINIMUM` lui reserve : il continue d'attraper une route d'operateur ajoutee sans etre classee, et attrape en plus une garde d'operateur posee par erreur sur une route de lecture.
339 lines
12 KiB
Python
339 lines
12 KiB
Python
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.energy import Reading, Site
|
|
from app.repositories.reading import ReadingRepository
|
|
from tests.repositories.test_site import creer as creer_site
|
|
from tests.repositories.test_site import identifiant as identifiant_site
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def identifiant() -> str:
|
|
return f"SITE-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading:
|
|
return Reading(
|
|
site_id=site_id,
|
|
timestamp=timestamp,
|
|
source="api_current",
|
|
consumption_kw=consumption_kw,
|
|
data_quality="good",
|
|
raw_data={},
|
|
)
|
|
|
|
|
|
async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading:
|
|
reading = Reading(
|
|
site_id=site_id,
|
|
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
|
source=overrides.get("source", "api_current"),
|
|
consumption_kw=overrides.get("consumption_kw", 10.0),
|
|
# Nul par defaut : seules les mesures en kWh alimentent la comparaison prevu/realise, et
|
|
# un override silencieusement ignore laissait la colonne vide sans que rien ne le dise.
|
|
consumption_kwh=overrides.get("consumption_kwh"),
|
|
data_quality=overrides.get("data_quality", "good"),
|
|
raw_data=overrides.get("raw_data", {}),
|
|
)
|
|
session.add(reading)
|
|
await session.flush()
|
|
return reading
|
|
|
|
|
|
async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
|
|
site_id = identifiant()
|
|
maintenant = datetime.now(UTC)
|
|
session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100))
|
|
await session.flush()
|
|
session.add_all(
|
|
[
|
|
lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10),
|
|
lecture(site_id, timestamp=maintenant, consumption_kw=42),
|
|
]
|
|
)
|
|
await session.flush()
|
|
depot = ReadingRepository(session)
|
|
|
|
resultats = await depot.latest_by_site()
|
|
consommations = [r.consumption_kw for r in resultats if r.site_id == site_id]
|
|
await session.rollback()
|
|
|
|
assert consommations == [42]
|
|
|
|
|
|
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
|
|
premier, second = identifiant(), identifiant()
|
|
maintenant = datetime.now(UTC)
|
|
session.add_all(
|
|
[
|
|
Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100),
|
|
Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200),
|
|
]
|
|
)
|
|
await session.flush()
|
|
session.add_all(
|
|
[
|
|
lecture(premier, timestamp=maintenant, consumption_kw=10),
|
|
lecture(second, timestamp=maintenant, consumption_kw=20),
|
|
]
|
|
)
|
|
await session.flush()
|
|
depot = ReadingRepository(session)
|
|
|
|
resultats = await depot.latest_by_site()
|
|
identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)}
|
|
await session.rollback()
|
|
|
|
assert identifiants == {premier, second}
|
|
|
|
|
|
async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
|
|
await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
|
|
)
|
|
derniere = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
|
|
)
|
|
|
|
resultats = await depot.latest_by_site()
|
|
retenues = [r.reading_id for r in resultats if r.site_id == site.site_id]
|
|
await session.rollback()
|
|
|
|
assert retenues == [derniere.reading_id]
|
|
|
|
|
|
async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
|
|
recente = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
|
)
|
|
|
|
trouvee = await depot.latest_for_site(site.site_id)
|
|
reading_id = trouvee.reading_id if trouvee else None
|
|
await session.rollback()
|
|
|
|
assert reading_id == recente.reading_id
|
|
|
|
|
|
async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
|
|
await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history")
|
|
derniere = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=horodatage, source="api_current"
|
|
)
|
|
|
|
trouvee = await depot.latest_for_site(site.site_id)
|
|
reading_id = trouvee.reading_id if trouvee else None
|
|
await session.rollback()
|
|
|
|
assert reading_id == derniere.reading_id
|
|
|
|
|
|
async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
sans_lecture = await creer_site(session)
|
|
autre = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
await creer_lecture(session, site_id=autre.site_id)
|
|
|
|
trouvee = await depot.latest_for_site(sans_lecture.site_id)
|
|
await session.rollback()
|
|
|
|
assert trouvee is None
|
|
|
|
|
|
async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
plus_recente = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
|
|
)
|
|
plus_ancienne = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
|
)
|
|
|
|
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id]
|
|
|
|
|
|
async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
dedans = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
|
|
)
|
|
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
|
|
|
|
resultats = await depot.list_since(
|
|
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
|
|
)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [dedans.reading_id]
|
|
|
|
|
|
async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
# `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source`
|
|
# diffère (même piège que `latest_for_site`). Sans ce départage, `_detect_spike` traiterait
|
|
# cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution.
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
horodatage = datetime(2026, 9, 16, tzinfo=UTC)
|
|
premiere = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
|
|
)
|
|
seconde = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
|
|
)
|
|
|
|
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [premiere.reading_id, seconde.reading_id]
|
|
|
|
|
|
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
|
|
premier = await creer_site(session)
|
|
second = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
voulue = await creer_lecture(session, site_id=premier.site_id)
|
|
await creer_lecture(session, site_id=second.site_id)
|
|
|
|
resultats = await depot.list_since(
|
|
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
|
|
)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [voulue.reading_id]
|
|
|
|
|
|
async def test_list_history_orders_the_readings_by_timestamp_descending(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
ancienne = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
|
|
)
|
|
recente = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
|
)
|
|
|
|
resultats = await depot.list_history(
|
|
start=datetime(2026, 8, 1, tzinfo=UTC),
|
|
end=datetime(2026, 10, 1, tzinfo=UTC),
|
|
limit=100,
|
|
offset=0,
|
|
)
|
|
identifiants = [
|
|
r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id)
|
|
]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [recente.reading_id, ancienne.reading_id]
|
|
|
|
|
|
async def test_list_history_filters_by_site_id(session: AsyncSession) -> None:
|
|
premier = await creer_site(session)
|
|
second = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
voulue = await creer_lecture(session, site_id=premier.site_id)
|
|
await creer_lecture(session, site_id=second.site_id)
|
|
|
|
resultats = await depot.list_history(
|
|
site_id=premier.site_id,
|
|
start=datetime(2026, 8, 1, tzinfo=UTC),
|
|
end=datetime(2026, 10, 1, tzinfo=UTC),
|
|
limit=100,
|
|
offset=0,
|
|
)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [voulue.reading_id]
|
|
|
|
|
|
async def test_list_history_excludes_readings_outside_the_window(session: AsyncSession) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
dedans = await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, 10, tzinfo=UTC)
|
|
)
|
|
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC))
|
|
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC))
|
|
|
|
resultats = await depot.list_history(
|
|
site_id=site.site_id,
|
|
start=datetime(2026, 9, 1, tzinfo=UTC),
|
|
end=datetime(2026, 9, 30, tzinfo=UTC),
|
|
limit=100,
|
|
offset=0,
|
|
)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [dedans.reading_id]
|
|
|
|
|
|
async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None:
|
|
site = await creer_site(session)
|
|
depot = ReadingRepository(session)
|
|
lectures = [
|
|
await creer_lecture(
|
|
session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC)
|
|
)
|
|
for jour in (1, 2, 3)
|
|
]
|
|
|
|
resultats = await depot.list_history(
|
|
site_id=site.site_id,
|
|
start=datetime(2026, 8, 1, tzinfo=UTC),
|
|
end=datetime(2026, 10, 1, tzinfo=UTC),
|
|
limit=1,
|
|
offset=1,
|
|
)
|
|
identifiants = [r.reading_id for r in resultats]
|
|
await session.rollback()
|
|
|
|
assert identifiants == [lectures[1].reading_id]
|
|
|
|
|
|
async def test_list_history_returns_an_empty_list_when_there_is_nothing(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
depot = ReadingRepository(session)
|
|
|
|
resultats = await depot.list_history(
|
|
site_id=identifiant_site(),
|
|
start=datetime(2026, 8, 1, tzinfo=UTC),
|
|
end=datetime(2026, 10, 1, tzinfo=UTC),
|
|
limit=100,
|
|
offset=0,
|
|
)
|
|
|
|
assert list(resultats) == []
|