"""M8-T02 tests for source profiles and transaction-owned binding services.""" from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from app.integrations.meter_sources import ( SECRET_MASK, SourceProfileError, merge_source_config, sanitize_source_config, validate_source_config, ) from app.models.energy import Meter from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel from app.services.meter_sources import ( BindingOverlapError, BindingValidationError, SourceDeleteRestrictedError, close_binding, create_binding, create_source, delete_source, upsert_discovered_channel, ) @pytest.mark.parametrize( ("kind", "config", "expected"), [ ("dsmr_mqtt", {}, {"broker_port": 1883, "topic": "dsmr/json", "sample_interval_s": 10}), ( "warmtelink_serial", {"path": "/dev/warmtelink"}, {"baudrate": 115200, "data_bits": 7, "parity": "N", "stop_bits": 1}, ), ], ) def test_profiles_fill_defaults(kind, config, expected): validated = validate_source_config(kind, config) assert {key: validated[key] for key in expected} == expected @pytest.mark.parametrize( ("kind", "config"), [ ("warmtelink_serial", {"path": "/tmp/warmtelink"}), ("warmtelink_serial", {"path": "/dev/warmtelink", "data_bits": 8}), ("dsmr_mqtt", {"extra": True}), ], ) def test_profile_rejects_invalid_or_unknown_config(kind, config): with pytest.raises(SourceProfileError): validate_source_config(kind, config) def test_secret_sanitize_and_mask_merge_keep_old_value(): original = validate_source_config("dsmr_mqtt", {"password": "not-for-response"}) assert sanitize_source_config("dsmr_mqtt", original)["password"] == SECRET_MASK merged = merge_source_config("dsmr_mqtt", original, {"password": SECRET_MASK, "topic": "new/topic"}) assert merged["password"] == "not-for-response" assert merged["topic"] == "new/topic" @pytest.fixture() def session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'source_services.db'}") @event.listens_for(engine, "connect") def _enable_foreign_keys(dbapi_connection, _connection_record): dbapi_connection.execute("PRAGMA foreign_keys=ON") from app.db import Base Base.metadata.create_all(engine) with Session(engine) as db_session: yield db_session engine.dispose() def _meter(session: Session, commodity: str, label: str = "Meter") -> Meter: timestamp = datetime(2026, 8, 22, tzinfo=UTC) meter = Meter( label=label, commodity=commodity, started_at=timestamp, reason="initial", created_at=timestamp, ) session.add(meter) session.flush() return meter def _source_and_channel(session: Session, kind: str, unit: str) -> tuple[MeterSource, MeterSourceChannel]: config = {"path": "/dev/warmtelink"} if kind == "warmtelink_serial" else {} source = create_source(session, name="Source", kind=kind, config=config) session.flush() channel = upsert_discovered_channel( session, source_id=source.id, channel_key=f"{unit}-total", label="Total", unit=unit, suggested_commodity="heating", ) session.flush() return source, channel def test_channel_upsert_is_idempotent_and_never_auto_binds(session): _, channel = _source_and_channel(session, "warmtelink_serial", "GJ") same = upsert_discovered_channel( session, source_id=channel.source_id, channel_key=channel.channel_key, label="Renamed total", unit="GJ", suggested_commodity="heating", ) session.flush() assert same.id == channel.id assert same.label == "Renamed total" assert session.query(MeterSourceBinding).count() == 0 def test_channel_upsert_rejects_unit_change_when_channel_is_bound(session): meter = _meter(session, "heating") _, channel = _source_and_channel(session, "warmtelink_serial", "GJ") create_binding(session, meter_id=meter.id, channel_id=channel.id, started_at=meter.started_at) session.flush() with pytest.raises(BindingValidationError, match="Cannot change unit of bound channel"): upsert_discovered_channel( session, source_id=channel.source_id, channel_key=channel.channel_key, label="Total", unit="m³", suggested_commodity="hot_water", ) assert channel.unit == "GJ" def test_binding_checks_both_sides_and_allows_equal_boundaries(session): meter_one = _meter(session, "heating", "one") meter_two = _meter(session, "heating", "two") _, channel_one = _source_and_channel(session, "warmtelink_serial", "GJ") _, channel_two = _source_and_channel(session, "warmtelink_serial", "GJ") start = datetime(2026, 8, 22, tzinfo=UTC) boundary = start + timedelta(hours=1) create_binding(session, meter_id=meter_one.id, channel_id=channel_one.id, started_at=start, ended_at=boundary) create_binding(session, meter_id=meter_one.id, channel_id=channel_two.id, started_at=boundary) create_binding(session, meter_id=meter_two.id, channel_id=channel_one.id, started_at=boundary) with pytest.raises(BindingOverlapError): create_binding(session, meter_id=meter_one.id, channel_id=channel_two.id, started_at=start + timedelta(minutes=30)) with pytest.raises(BindingOverlapError): create_binding(session, meter_id=meter_two.id, channel_id=channel_one.id, started_at=start + timedelta(minutes=30)) def test_binding_rejects_incompatible_unit_and_close_keeps_transaction_open(session): meter = _meter(session, "electricity") _, channel = _source_and_channel(session, "warmtelink_serial", "GJ") with pytest.raises(BindingValidationError): create_binding(session, meter_id=meter.id, channel_id=channel.id, started_at=meter.started_at) thermal_meter = _meter(session, "heating", "thermal") binding = create_binding(session, meter_id=thermal_meter.id, channel_id=channel.id, started_at=meter.started_at) session.flush() close_binding(session, binding.id, ended_at=meter.started_at + timedelta(minutes=1)) assert session.in_transaction() session.rollback() assert session.get(MeterSourceBinding, binding.id) is None def test_source_delete_is_restricted_by_discovered_channel(session): source, _ = _source_and_channel(session, "dsmr_mqtt", "kWh") with pytest.raises(SourceDeleteRestrictedError): delete_source(session, source.id)