Files
home-automation/tests/test_warmtelink_models.py
T

253 lines
11 KiB
Python
Raw Normal View History

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
from scripts.app_db_adopt import APP_BASELINE_REVISION
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)
command.upgrade(empty_config, "head")
command.upgrade(empty_config, "head")
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()
command.upgrade(config, "head")
command.upgrade(config, "head")
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)
command.upgrade(config, "head")
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
assert APP_BASELINE_REVISION == REVISION_17
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)
command.upgrade(config, "head")
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()