"""Migration and ORM contracts for the commodity-scoped meter cost ledger.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from decimal import Decimal from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import create_engine, event as sa_event, insert, inspect, select, text from sqlalchemy.exc import IntegrityError, StatementError from sqlalchemy.orm import Session from app.models.energy import MeterCostPeriod from scripts.app_db_adopt import APP_BASELINE_REVISION REVISION_18 = "20260822_18_contract_scopes" REVISION_19 = "20260822_19_meter_cost_periods" UTC = timezone.utc def _config(database_url: str) -> Config: config = Config("alembic_app.ini") config.set_main_option("sqlalchemy.url", database_url) return config def _fk_engine(database_url: str): engine = create_engine(database_url) @sa_event.listens_for(engine, "connect") def _enable_foreign_keys(connection, _record) -> None: connection.execute("PRAGMA foreign_keys = ON") return engine def _period(**overrides) -> MeterCostPeriod: start = datetime(2026, 8, 22, 12, tzinfo=UTC) values = { "commodity": "heating", "period_start": start, "period_end": start + timedelta(minutes=15), "quantity": Decimal("999999999.123456"), "cost": Decimal("999999.123456789"), "currency": "EUR", "cost_breakdown": {"heating": "0.012345678"}, "pricing_snapshot": {"heating": "10.000000", "kind": "district_heating"}, "quality": "valid", "degraded": True, "degraded_reason": "test_fixture_without_audit_links", "created_at": start, "updated_at": start, } values.update(overrides) return MeterCostPeriod(**values) def test_meter_cost_period_empty_db_upgrade_shape_and_baseline(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'empty.db'}" config = _config(database_url) command.upgrade(config, "head") command.upgrade(config, "head") engine = create_engine(database_url) inspector = inspect(engine) columns = {column["name"]: column for column in inspector.get_columns("meter_cost_period")} assert APP_BASELINE_REVISION == REVISION_19 assert {"commodity", "period_start", "period_end", "quantity", "cost"} <= columns.keys() assert columns["meter_id"]["nullable"] assert columns["source_binding_id"]["nullable"] assert columns["contract_version_id"]["nullable"] assert {index["name"] for index in inspector.get_indexes("meter_cost_period")} >= { "ix_meter_cost_period_commodity_start", "ix_meter_cost_period_source_binding_id", } assert ("commodity", "period_start") in { tuple(constraint["column_names"]) for constraint in inspector.get_unique_constraints("meter_cost_period") } foreign_keys = { foreign_key["constrained_columns"][0]: foreign_key for foreign_key in inspector.get_foreign_keys("meter_cost_period") } for column, table in { "meter_id": "meter", "source_binding_id": "meter_source_binding", "contract_version_id": "energy_contract_version", }.items(): assert foreign_keys[column]["referred_table"] == table assert foreign_keys[column]["options"]["ondelete"] == "RESTRICT" engine.dispose() def test_meter_cost_period_revision_18_upgrade_preserves_electricity_rows(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'revision18.db'}" config = _config(database_url) command.upgrade(config, REVISION_18) engine = create_engine(database_url) stamp = datetime(2026, 8, 22, 12) with engine.begin() as connection: connection.execute( text( "INSERT INTO energy_cost_period " "(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, import_cost, " "export_revenue, net_cost, currency, pricing, contract_version_id, " "meter_id, source_binding_id, degraded, computed_at) " "VALUES (:stamp, 1, 2, 3, 4, 5, 6, 7, 'EUR', :pricing, NULL, NULL, NULL, 0, :stamp)" ), {"stamp": stamp, "pricing": '{"historic":"unchanged"}'}, ) command.upgrade(config, "head") command.upgrade(config, "head") with engine.connect() as connection: assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == REVISION_19 assert connection.execute(text("SELECT COUNT(*) FROM energy_cost_period")).scalar_one() == 1 assert connection.execute(text("SELECT pricing FROM energy_cost_period")).scalar_one() == ( '{"historic":"unchanged"}' ) assert connection.execute(text("SELECT COUNT(*) FROM meter_cost_period")).scalar_one() == 0 command.downgrade(config, REVISION_18) assert "meter_cost_period" not in inspect(engine).get_table_names() engine.dispose() def test_meter_cost_period_decimal_json_and_degraded_round_trip(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'roundtrip.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) with Session(engine) as session: normal = _period() degraded = _period( commodity="hot_water", meter_id=None, source_binding_id=None, contract_version_id=None, quality="unverifiable", degraded=True, degraded_reason="missing_binding", quantity=Decimal("0.000000"), cost=Decimal("0.000000000"), cost_breakdown={"hot_water": "0.000000000"}, pricing_snapshot={"reason": "missing_binding"}, ) session.add_all((normal, degraded)) session.commit() session.expire_all() stored = session.get(MeterCostPeriod, normal.id) assert stored is not None assert stored.quantity == Decimal("999999999.123456") assert stored.cost == Decimal("999999.123456789") assert stored.cost_breakdown == {"heating": "0.012345678"} assert all(not isinstance(value, float) for value in stored.pricing_snapshot.values()) assert session.get(MeterCostPeriod, degraded.id).degraded_reason == "missing_binding" engine.dispose() def test_meter_cost_period_uses_decimal_text_and_normalises_json_snapshots(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'exact.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) with Session(engine) as session: row = _period( cost_breakdown={"nested": [Decimal("2.000000000")]}, pricing_snapshot={"rate": Decimal("2.000000")}, ) session.add(row) session.commit() session.expire_all() stored = session.get(MeterCostPeriod, row.id) assert stored is not None assert stored.cost_breakdown == {"nested": ["2.000000000"]} assert stored.pricing_snapshot == {"rate": "2.000000"} raw = session.execute(text("SELECT typeof(quantity), typeof(cost) FROM meter_cost_period")).one() assert raw == ("text", "text") with pytest.raises(ValueError, match="numeric JSON"): _period(cost_breakdown={"nested": [1]}) with pytest.raises(ValueError, match="numeric JSON"): _period(pricing_snapshot={"nested": [1.25]}) engine.dispose() def test_meter_cost_period_core_json_bind_normalises_decimal_and_rejects_numeric(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'core-json.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) values = { "commodity": "heating", "period_start": datetime(2026, 8, 22, 12, tzinfo=UTC), "period_end": datetime(2026, 8, 22, 12, 15, tzinfo=UTC), "quantity": Decimal("0.000000"), "cost": Decimal("0.000000000"), "currency": "EUR", "cost_breakdown": {"nested": [Decimal("2.000000000")]}, "pricing_snapshot": {"rate": Decimal("2.000000")}, "quality": "valid", "degraded": True, "degraded_reason": "core fixture", "created_at": datetime(2026, 8, 22, 12, tzinfo=UTC), "updated_at": datetime(2026, 8, 22, 12, tzinfo=UTC), } with engine.begin() as connection: connection.execute(insert(MeterCostPeriod.__table__).values(values)) with Session(engine) as session: stored = session.scalar(select(MeterCostPeriod)) assert stored is not None assert stored.cost_breakdown == {"nested": ["2.000000000"]} assert stored.pricing_snapshot == {"rate": "2.000000"} for field, numeric_value in (("cost_breakdown", {"nested": [1]}), ("pricing_snapshot", {"rate": 1.25})): with engine.begin() as connection, pytest.raises(StatementError, match="numeric JSON"): connection.execute( insert(MeterCostPeriod.__table__).values( {**values, "commodity": f"invalid-{field}", field: numeric_value} ) ) engine.dispose() @pytest.mark.parametrize( ("field", "value", "message"), [ ("quantity", Decimal("1000000000.000000"), "precision"), ("quantity", Decimal("1.1234567"), "scale"), ("cost", Decimal("1000000.000000000"), "precision"), ("cost", Decimal("1.1234567899"), "scale"), ], ) def test_meter_cost_period_rejects_decimal_precision_and_scale_overflow(field, value, message) -> None: with pytest.raises(ValueError, match=message): _period(**{field: value}) def test_meter_cost_period_accepts_signed_decimal_boundaries_without_float_bind(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'boundaries.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) with Session(engine) as session: rows = ( _period(quantity=Decimal("999999999.999999"), cost=Decimal("999999.999999999")), _period( commodity="hot_water", quantity=Decimal("-999999999.999999"), cost=Decimal("-999999.999999999"), ), ) session.add_all(rows) session.commit() session.expire_all() assert session.get(MeterCostPeriod, rows[0].id).quantity == Decimal("999999999.999999") assert session.get(MeterCostPeriod, rows[1].id).cost == Decimal("-999999.999999999") engine.dispose() def test_meter_cost_period_rejects_duplicate_period_invalid_interval_and_json_float(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'constraints.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) with Session(engine) as session: first = _period() session.add(first) session.commit() session.add(_period(cost=Decimal("1.000000000"))) with pytest.raises(IntegrityError): session.commit() session.rollback() session.add(_period(commodity="hot_water", period_end=datetime(2026, 8, 22, 12, tzinfo=UTC))) with pytest.raises(ValueError, match="after period_start"): session.flush() session.rollback() with pytest.raises(ValueError, match="timezone-aware"): _period( commodity="hot_water", period_start=datetime(2026, 8, 22, 12), period_end=datetime(2026, 8, 22, 12, 15, tzinfo=UTC), ) with pytest.raises(ValueError, match="decimal strings"): _period(pricing_snapshot={"heating": 1.25}) with pytest.raises(ValueError, match="normal meter cost periods"): session.add(_period(commodity="hot_water", degraded=False, degraded_reason=None)) session.flush() session.rollback() engine.dispose() def test_meter_cost_period_uses_utc_instant_idempotency_and_db_constraints(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'utc.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC) offset_start = datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2))) with Session(engine) as session: first = _period(period_start=utc_start, period_end=utc_start + timedelta(minutes=15)) assert first.period_start == utc_start session.add(first) session.commit() session.add( _period( period_start=offset_start, period_end=offset_start + timedelta(minutes=15), cost=Decimal("1.000000000"), ) ) with pytest.raises(IntegrityError): session.commit() session.rollback() with engine.begin() as connection: values = { "start": "2026-08-23 00:00:00.000000", "quantity": "0.000000", "cost": "0.000000000", "now": "2026-08-23 00:00:00.000000", } for commodity, end in (("equal", values["start"]), ("reversed", "2026-08-22 23:59:59.000000")): with pytest.raises(IntegrityError): connection.execute( text( "INSERT INTO meter_cost_period " "(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, " "pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) " "VALUES (:commodity, :start, :end, :quantity, :cost, 'EUR', '{}', '{}', 'valid', " "1, 'core test', :now, :now)" ), {**values, "commodity": commodity, "end": end}, ) with pytest.raises(IntegrityError): connection.execute( text( "INSERT INTO meter_cost_period " "(commodity, period_start, period_end, quantity, cost, currency, cost_breakdown, " "pricing_snapshot, quality, degraded, degraded_reason, created_at, updated_at) " "VALUES ('missing-reason', :start, '2026-08-23 00:15:00.000000', :quantity, :cost, " "'EUR', '{}', '{}', 'valid', 1, NULL, :now, :now)" ), values, ) engine.dispose() def test_meter_cost_period_core_uses_utc_instant_idempotency_and_reloads_aware(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'core-utc.db'}" command.upgrade(_config(database_url), "head") engine = create_engine(database_url) utc_start = datetime(2026, 8, 22, 12, tzinfo=UTC) values = { "commodity": "heating", "period_start": utc_start, "period_end": utc_start + timedelta(minutes=15), "quantity": Decimal("0.000000"), "cost": Decimal("0.000000000"), "currency": "EUR", "cost_breakdown": {}, "pricing_snapshot": {}, "quality": "valid", "degraded": True, "degraded_reason": "core fixture", "created_at": utc_start, "updated_at": utc_start, } with engine.begin() as connection: row_id = connection.execute(insert(MeterCostPeriod.__table__).values(values)).inserted_primary_key[0] with engine.begin() as connection, pytest.raises(IntegrityError): connection.execute( insert(MeterCostPeriod.__table__).values( { **values, "period_start": datetime(2026, 8, 22, 14, tzinfo=timezone(timedelta(hours=2))), "period_end": datetime(2026, 8, 22, 14, 15, tzinfo=timezone(timedelta(hours=2))), } ) ) with engine.connect() as connection, pytest.raises(StatementError, match="timezone-aware"): connection.execute( insert(MeterCostPeriod.__table__).values( {**values, "commodity": "naive", "period_start": datetime(2026, 8, 23, 12)} ) ) with Session(engine) as session: stored = session.get(MeterCostPeriod, row_id) assert stored is not None assert stored.period_start == utc_start assert stored.period_start.tzinfo is not None assert stored.period_start.utcoffset() == timedelta(0) stored.quality = "unverifiable" session.commit() engine.dispose() def test_meter_cost_period_foreign_keys_restrict_deletion(tmp_path: Path) -> None: database_url = f"sqlite:///{tmp_path / 'foreign_keys.db'}" command.upgrade(_config(database_url), "head") engine = _fk_engine(database_url) timestamp = datetime(2026, 8, 22, 12) with engine.begin() as connection: meter_id = connection.execute( text( "INSERT INTO meter (uuid, label, commodity, started_at, ended_at, reason, note, created_at) " "VALUES ('meter-cost-test', 'Test', 'heating', :timestamp, NULL, 'initial', NULL, :timestamp)" ), {"timestamp": timestamp}, ).lastrowid source_id = connection.execute( text( "INSERT INTO meter_source (uuid, name, kind, enabled, config, status, created_at, updated_at) " "VALUES ('source-cost-test', 'Test', 'warmtelink_serial', 1, '{}', 'online', :timestamp, :timestamp)" ), {"timestamp": timestamp}, ).lastrowid channel_id = connection.execute( text( "INSERT INTO meter_source_channel (uuid, source_id, channel_key, label, unit, created_at, updated_at) " "VALUES ('channel-cost-test', :source_id, 'heating', 'Heating', 'GJ', :timestamp, :timestamp)" ), {"source_id": source_id, "timestamp": timestamp}, ).lastrowid binding_id = connection.execute( text( "INSERT INTO meter_source_binding " "(uuid, meter_id, channel_id, started_at, ended_at, created_at, updated_at) " "VALUES ('binding-cost-test', :meter_id, :channel_id, :timestamp, NULL, :timestamp, :timestamp)" ), {"meter_id": meter_id, "channel_id": channel_id, "timestamp": timestamp}, ).lastrowid contract_id = connection.execute( text( "INSERT INTO energy_contract (name, kind, scope, active, currency, created_at, updated_at) " "VALUES ('Test', 'district_heating', 'thermal', 1, 'EUR', :timestamp, :timestamp)" ), {"timestamp": timestamp}, ).lastrowid version_id = connection.execute( text( "INSERT INTO energy_contract_version " "(contract_id, effective_from, effective_to, \"values\", created_at) " "VALUES (:contract_id, :timestamp, NULL, '{}', :timestamp)" ), {"contract_id": contract_id, "timestamp": timestamp}, ).lastrowid connection.execute( text( "INSERT INTO meter_cost_period " "(commodity, period_start, period_end, meter_id, source_binding_id, contract_version_id, " "quantity, cost, currency, cost_breakdown, pricing_snapshot, quality, degraded, degraded_reason, " "created_at, updated_at) VALUES " "('heating', :timestamp, :period_end, :meter_id, :binding_id, :version_id, " "'0.001000', '0.010000000', 'EUR', '{}', '{}', 'valid', 0, NULL, :timestamp, :timestamp)" ), { "timestamp": timestamp, "period_end": timestamp + timedelta(minutes=15), "meter_id": meter_id, "binding_id": binding_id, "version_id": version_id, }, ) for statement, values in ( ("DELETE FROM meter WHERE id = :id", {"id": meter_id}), ("DELETE FROM meter_source_binding WHERE id = :id", {"id": binding_id}), ("DELETE FROM energy_contract_version WHERE id = :id", {"id": version_id}), ): with pytest.raises(IntegrityError): connection.execute(text(statement), values) engine.dispose()