M8-T02: add source profiles and binding services
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user