"""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()