2026-08-23 04:08:44 +02:00
|
|
|
"""Schema and migration tests for the normalized WarmteLink reading table."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import sqlalchemy.exc
|
|
|
|
|
from alembic import command
|
|
|
|
|
from alembic.config import Config
|
|
|
|
|
from sqlalchemy import create_engine, event, inspect, text
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app.models.meter_source import MeterSourceChannel, WarmteLinkReading
|
|
|
|
|
REVISION_16 = "20260822_16_dsmr_source_adoption"
|
|
|
|
|
REVISION_17 = "20260822_17_warmtelink_readings"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _config(database_url: str) -> Config:
|
|
|
|
|
config = Config("alembic_app.ini")
|
|
|
|
|
config.set_main_option("sqlalchemy.url", database_url)
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _engine(database_url: str):
|
|
|
|
|
engine = create_engine(database_url, connect_args={"check_same_thread": False})
|
|
|
|
|
|
|
|
|
|
@event.listens_for(engine, "connect")
|
|
|
|
|
def _foreign_keys(connection, _record) -> None:
|
|
|
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
|
|
|
|
|
|
|
|
return engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_channel(connection, timestamp: datetime, channel_key: str = "heating-total") -> int:
|
|
|
|
|
source_id = connection.execute(
|
|
|
|
|
text("SELECT id FROM meter_source WHERE kind = 'dsmr_mqtt'")
|
|
|
|
|
).scalar_one()
|
|
|
|
|
connection.execute(
|
|
|
|
|
text(
|
|
|
|
|
"INSERT INTO meter_source_channel "
|
|
|
|
|
"(uuid, source_id, channel_key, label, suggested_commodity, unit, device_type, "
|
|
|
|
|
"fingerprint, latest_value, latest_at, latest_quality, created_at, updated_at) "
|
|
|
|
|
"VALUES (:uuid, :source_id, :channel_key, 'WarmteLink channel', 'heating', 'GJ', "
|
|
|
|
|
"'warmtelink', NULL, NULL, NULL, NULL, :at, :at)"
|
|
|
|
|
),
|
|
|
|
|
{
|
|
|
|
|
"uuid": f"{channel_key[:8]:0<8}-0000-4000-8000-000000000000",
|
|
|
|
|
"source_id": source_id,
|
|
|
|
|
"channel_key": channel_key,
|
|
|
|
|
"at": timestamp,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return int(connection.execute(text("SELECT last_insert_rowid()")).scalar_one())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_reading(connection, channel_id: int, timestamp: datetime, value: Decimal) -> None:
|
|
|
|
|
connection.execute(
|
|
|
|
|
text(
|
|
|
|
|
"INSERT INTO warmtelink_reading "
|
|
|
|
|
"(channel_id, recorded_at, received_at, value, unit, quality, equipment_fingerprint) "
|
|
|
|
|
"VALUES (:channel_id, :recorded_at, :received_at, :value, 'GJ', 'unverifiable', :fingerprint)"
|
|
|
|
|
),
|
|
|
|
|
{
|
|
|
|
|
"channel_id": channel_id,
|
|
|
|
|
"recorded_at": timestamp,
|
|
|
|
|
"received_at": timestamp,
|
|
|
|
|
"value": str(value),
|
|
|
|
|
"fingerprint": "a" * 64,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_database_and_revision_16_upgrade_are_additive_and_idempotent(tmp_path: Path):
|
|
|
|
|
empty_url = f"sqlite:///{tmp_path / 'warmtelink_empty.db'}"
|
|
|
|
|
empty_config = _config(empty_url)
|
2026-08-23 08:18:42 +02:00
|
|
|
command.upgrade(empty_config, REVISION_17)
|
|
|
|
|
command.upgrade(empty_config, REVISION_17)
|
2026-08-23 04:08:44 +02:00
|
|
|
empty_engine = _engine(empty_url)
|
|
|
|
|
try:
|
|
|
|
|
with empty_engine.connect() as connection:
|
|
|
|
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_17
|
|
|
|
|
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
|
|
|
|
finally:
|
|
|
|
|
empty_engine.dispose()
|
|
|
|
|
|
|
|
|
|
database_url = f"sqlite:///{tmp_path / 'warmtelink_upgrade.db'}"
|
|
|
|
|
config = _config(database_url)
|
|
|
|
|
timestamp = datetime(2026, 8, 22, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
command.upgrade(config, REVISION_16)
|
|
|
|
|
engine = _engine(database_url)
|
|
|
|
|
try:
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
channel_id = _add_channel(connection, timestamp)
|
|
|
|
|
before_counts = {
|
|
|
|
|
table: int(connection.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar_one())
|
|
|
|
|
for table in ("dsmr_reading", "meter_source", "meter_source_channel", "meter_source_binding")
|
|
|
|
|
}
|
|
|
|
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_16
|
|
|
|
|
assert channel_id > 0
|
|
|
|
|
finally:
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
2026-08-23 08:18:42 +02:00
|
|
|
command.upgrade(config, REVISION_17)
|
|
|
|
|
command.upgrade(config, REVISION_17)
|
2026-08-23 04:08:44 +02:00
|
|
|
engine = _engine(database_url)
|
|
|
|
|
try:
|
|
|
|
|
with engine.connect() as connection:
|
|
|
|
|
after_counts = {
|
|
|
|
|
table: int(connection.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar_one())
|
|
|
|
|
for table in before_counts
|
|
|
|
|
}
|
|
|
|
|
assert after_counts == before_counts
|
|
|
|
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_17
|
|
|
|
|
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
|
|
|
|
finally:
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_warmtelink_reading_constraints_indexes_and_decimal_round_trip(tmp_path: Path):
|
|
|
|
|
database_url = f"sqlite:///{tmp_path / 'warmtelink_constraints.db'}"
|
|
|
|
|
config = _config(database_url)
|
2026-08-23 08:18:42 +02:00
|
|
|
command.upgrade(config, REVISION_17)
|
2026-08-23 04:08:44 +02:00
|
|
|
timestamp = datetime(2026, 8, 22, 10, 30, tzinfo=timezone.utc)
|
|
|
|
|
engine = _engine(database_url)
|
|
|
|
|
try:
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
first_channel = _add_channel(connection, timestamp)
|
|
|
|
|
second_channel = _add_channel(connection, timestamp, "hot-water-total")
|
|
|
|
|
_insert_reading(connection, first_channel, timestamp, Decimal("12345.678"))
|
|
|
|
|
_insert_reading(connection, second_channel, timestamp, Decimal("4.125"))
|
|
|
|
|
|
|
|
|
|
with Session(engine) as session:
|
|
|
|
|
reading = session.query(WarmteLinkReading).filter_by(channel_id=first_channel).one()
|
|
|
|
|
assert reading.value == Decimal("12345.678")
|
|
|
|
|
assert reading.recorded_at == timestamp
|
|
|
|
|
assert reading.received_at == timestamp
|
|
|
|
|
session.add(
|
|
|
|
|
WarmteLinkReading(
|
|
|
|
|
channel_id=first_channel,
|
|
|
|
|
recorded_at=timestamp + timedelta(minutes=1),
|
|
|
|
|
received_at=timestamp + timedelta(minutes=1),
|
|
|
|
|
value=Decimal("234.567"),
|
|
|
|
|
unit="GJ",
|
|
|
|
|
quality="valid",
|
|
|
|
|
equipment_fingerprint="c" * 64,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
session.commit()
|
|
|
|
|
assert session.query(WarmteLinkReading).filter_by(value=Decimal("234.567")).one().value == Decimal(
|
|
|
|
|
"234.567"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Numeric(15, 3) promises twelve integer digits. Exercise its
|
|
|
|
|
# declared upper boundary through the SQLite ORM path, rather than
|
|
|
|
|
# a raw SQL value that could bypass its Decimal result processor.
|
|
|
|
|
boundary = Decimal("999999999999.999")
|
|
|
|
|
session.add(
|
|
|
|
|
WarmteLinkReading(
|
|
|
|
|
channel_id=second_channel,
|
|
|
|
|
recorded_at=timestamp + timedelta(minutes=1),
|
|
|
|
|
received_at=timestamp + timedelta(minutes=1),
|
|
|
|
|
value=boundary,
|
|
|
|
|
unit="GJ",
|
|
|
|
|
quality="valid",
|
|
|
|
|
equipment_fingerprint="d" * 64,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
session.commit()
|
|
|
|
|
session.expire_all()
|
|
|
|
|
assert (
|
|
|
|
|
session.query(WarmteLinkReading)
|
|
|
|
|
.filter_by(channel_id=second_channel, recorded_at=timestamp + timedelta(minutes=1))
|
|
|
|
|
.one()
|
|
|
|
|
.value
|
|
|
|
|
== boundary
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
_insert_reading(connection, first_channel, timestamp, Decimal("5.000"))
|
|
|
|
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
connection.execute(
|
|
|
|
|
text(
|
|
|
|
|
"INSERT INTO warmtelink_reading "
|
|
|
|
|
"(channel_id, recorded_at, received_at, value, unit, quality, equipment_fingerprint) "
|
|
|
|
|
"VALUES (:channel_id, :at, :at, 1.000, 'GJ', 'estimated-valid', :fingerprint)"
|
|
|
|
|
),
|
|
|
|
|
{"channel_id": first_channel, "at": timestamp, "fingerprint": "b" * 64},
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
connection.execute(text("DELETE FROM meter_source_channel WHERE id = :id"), {"id": first_channel})
|
|
|
|
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
|
|
|
|
with engine.begin() as connection:
|
|
|
|
|
connection.execute(
|
|
|
|
|
text("DELETE FROM meter_source WHERE id = (SELECT source_id FROM meter_source_channel WHERE id = :id)"),
|
|
|
|
|
{"id": first_channel},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
inspector = inspect(engine)
|
|
|
|
|
columns = {column["name"]: column for column in inspector.get_columns("warmtelink_reading")}
|
|
|
|
|
assert columns["value"]["type"].precision == 15
|
|
|
|
|
assert columns["value"]["type"].scale == 3
|
|
|
|
|
assert "raw_telegram" not in columns
|
|
|
|
|
assert "equipment_identifier" not in columns
|
|
|
|
|
assert "raw_identifier" not in columns
|
|
|
|
|
assert {tuple(item["column_names"]) for item in inspector.get_unique_constraints("warmtelink_reading")} >= {
|
|
|
|
|
("channel_id", "recorded_at"),
|
|
|
|
|
}
|
|
|
|
|
foreign_key = inspector.get_foreign_keys("warmtelink_reading")[0]
|
|
|
|
|
assert foreign_key["constrained_columns"] == ["channel_id"]
|
|
|
|
|
assert foreign_key["referred_table"] == "meter_source_channel"
|
|
|
|
|
assert foreign_key["options"]["ondelete"] == "RESTRICT"
|
|
|
|
|
assert "ix_warmtelink_reading_recorded_at" in {
|
|
|
|
|
index["name"] for index in inspector.get_indexes("warmtelink_reading")
|
|
|
|
|
}
|
|
|
|
|
with engine.connect() as connection:
|
|
|
|
|
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
|
|
|
|
finally:
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_warmtelink_reading_model_uses_restrictive_relationship_and_aware_columns():
|
|
|
|
|
relationship = MeterSourceChannel.warmtelink_readings.property
|
|
|
|
|
assert "delete" not in relationship.cascade
|
|
|
|
|
assert "delete-orphan" not in relationship.cascade
|
|
|
|
|
assert WarmteLinkReading.__table__.c.recorded_at.type.timezone is True
|
|
|
|
|
assert WarmteLinkReading.__table__.c.received_at.type.timezone is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_warmtelink_reading_downgrade_is_schema_only_on_temporary_database(tmp_path: Path):
|
|
|
|
|
database_url = f"sqlite:///{tmp_path / 'warmtelink_downgrade.db'}"
|
|
|
|
|
config = _config(database_url)
|
2026-08-23 08:18:42 +02:00
|
|
|
command.upgrade(config, REVISION_17)
|
2026-08-23 04:08:44 +02:00
|
|
|
command.downgrade(config, REVISION_16)
|
|
|
|
|
|
|
|
|
|
engine = _engine(database_url)
|
|
|
|
|
try:
|
|
|
|
|
with engine.connect() as connection:
|
|
|
|
|
assert "warmtelink_reading" not in set(inspect(engine).get_table_names())
|
|
|
|
|
assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_16
|
|
|
|
|
assert connection.execute(text("PRAGMA foreign_key_check")).all() == []
|
|
|
|
|
finally:
|
|
|
|
|
engine.dispose()
|