466 lines
19 KiB
Python
466 lines
19 KiB
Python
"""Tests for M5-T04: Modbus polling service (app/services/modbus_poll.py).
|
|
|
|
Coverage:
|
|
1. poll_all_enabled_devices inserts +N reading rows with correct payload
|
|
when the driver returns known register data.
|
|
2. A device whose driver raises leaves last_poll_ok=False; other devices
|
|
in the same sweep are still polled and their readings are stored.
|
|
3. modbus_polling_enabled=False causes the entire sweep to be skipped
|
|
(no readings inserted, no driver calls made).
|
|
4. Per-device interval: a device whose last_poll_at is too recent is
|
|
skipped; one whose interval has elapsed is polled.
|
|
5. poll_device returns None on driver failure and does not propagate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from sqlalchemy import create_engine, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.models.modbus import ModbusDevice, ModbusReading
|
|
from app.services.modbus_poll import poll_all_enabled_devices, poll_device
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers and fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_app_alembic_config(database_url: str) -> Config:
|
|
cfg = Config("alembic_app.ini")
|
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
|
return cfg
|
|
|
|
|
|
@pytest.fixture()
|
|
def poll_db(tmp_path: Path):
|
|
"""Temporary SQLite DB upgraded to Alembic head, yields (engine, session_factory)."""
|
|
db_path = tmp_path / "poll_test.db"
|
|
db_url = f"sqlite:///{db_path}"
|
|
command.upgrade(_make_app_alembic_config(db_url), "head")
|
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
|
|
def _make_device(
|
|
session: Session,
|
|
*,
|
|
friendly_name: str = "Test Meter",
|
|
host: str = "10.0.0.1",
|
|
port: int = 502,
|
|
unit_id: int = 1,
|
|
profile: str = "sdm120",
|
|
poll_interval_s: int = 5,
|
|
enabled: bool = True,
|
|
last_poll_at: datetime | None = None,
|
|
last_poll_ok: bool | None = None,
|
|
) -> ModbusDevice:
|
|
now = datetime.now(UTC)
|
|
device = ModbusDevice(
|
|
friendly_name=friendly_name,
|
|
host=host,
|
|
port=port,
|
|
unit_id=unit_id,
|
|
profile=profile,
|
|
poll_interval_s=poll_interval_s,
|
|
enabled=enabled,
|
|
last_poll_at=last_poll_at,
|
|
last_poll_ok=last_poll_ok,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(device)
|
|
session.commit()
|
|
return device
|
|
|
|
|
|
# Known payload that matches what decode() should produce for a minimal SDM120 profile.
|
|
KNOWN_PAYLOAD: dict[str, float] = {
|
|
"voltage": 230.20001220703125,
|
|
"current": 1.2999999523162842,
|
|
"active_power": 295.0,
|
|
"power_factor": 0.9800000190734863,
|
|
"frequency": 50.0,
|
|
"import_energy": 123.4000015258789,
|
|
"export_energy": 0.0,
|
|
"total_energy": 123.4000015258789,
|
|
}
|
|
|
|
|
|
def _make_bootstrap_settings(**overrides) -> Settings:
|
|
"""Return a Settings instance with test defaults and optional overrides."""
|
|
return Settings(
|
|
_env_file=None,
|
|
app_database_url="sqlite:///:memory:",
|
|
auth_bootstrap_password="test",
|
|
**overrides,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Successful poll inserts +N readings with correct payload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_all_inserts_readings_for_enabled_devices(poll_db):
|
|
"""poll_all_enabled_devices inserts one reading per enabled device with correct payload."""
|
|
with Session(poll_db) as session:
|
|
d1 = _make_device(session, friendly_name="Meter A", host="10.0.0.1")
|
|
d2 = _make_device(session, friendly_name="Meter B", host="10.0.0.2")
|
|
|
|
# Verify baseline: no readings yet.
|
|
count_before = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(count_before) == 0
|
|
|
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
|
|
|
with (
|
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
|
):
|
|
mock_profile = MagicMock()
|
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
|
mock_load.return_value = mock_profile
|
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
|
mock_decode.return_value = KNOWN_PAYLOAD
|
|
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 2, f"Expected 2 readings, got {len(readings)}"
|
|
|
|
# Both readings should have the known payload.
|
|
for reading in readings:
|
|
assert reading.payload == KNOWN_PAYLOAD, (
|
|
f"Payload mismatch for reading id={reading.id}: {reading.payload}"
|
|
)
|
|
assert reading.recorded_at is not None
|
|
|
|
# Devices should be marked last_poll_ok=True.
|
|
session.expire_all()
|
|
fetched_d1 = session.get(ModbusDevice, d1.id)
|
|
fetched_d2 = session.get(ModbusDevice, d2.id)
|
|
assert fetched_d1.last_poll_ok is True
|
|
assert fetched_d2.last_poll_ok is True
|
|
assert fetched_d1.last_poll_at is not None
|
|
assert fetched_d2.last_poll_at is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. One device fails — others still succeed; failed device gets last_poll_ok=False
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_all_isolates_device_failure(poll_db):
|
|
"""A driver error for one device must not prevent other devices from being polled."""
|
|
with Session(poll_db) as session:
|
|
d_ok = _make_device(session, friendly_name="OK Meter", host="10.0.0.1")
|
|
d_fail = _make_device(session, friendly_name="Broken Meter", host="10.0.0.2")
|
|
|
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def fake_read_blocks(host, port, unit_id, blocks, **kwargs):
|
|
call_count["n"] += 1
|
|
if host == "10.0.0.2":
|
|
raise ConnectionError("Cannot connect to broken meter")
|
|
return {0x0000: 0x4366, 0x0001: 0x3334}
|
|
|
|
with (
|
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
|
patch("app.services.modbus_poll.driver.read_blocks", side_effect=fake_read_blocks),
|
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
|
):
|
|
mock_profile = MagicMock()
|
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
|
mock_load.return_value = mock_profile
|
|
mock_decode.return_value = KNOWN_PAYLOAD
|
|
|
|
# Must not raise even though one device fails.
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
|
|
# Only the OK meter should have a reading.
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 1, f"Expected 1 reading (OK meter only), got {len(readings)}"
|
|
assert readings[0].device_id == d_ok.id
|
|
|
|
# driver was called for both devices (fail attempt included).
|
|
assert call_count["n"] == 2
|
|
|
|
session.expire_all()
|
|
fetched_ok = session.get(ModbusDevice, d_ok.id)
|
|
fetched_fail = session.get(ModbusDevice, d_fail.id)
|
|
assert fetched_ok.last_poll_ok is True
|
|
assert fetched_fail.last_poll_ok is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. modbus_polling_enabled=False → entire sweep is skipped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_all_skipped_when_polling_disabled(poll_db):
|
|
"""When modbus_polling_enabled=False, no devices are polled and no readings are inserted."""
|
|
with Session(poll_db) as session:
|
|
_make_device(session, friendly_name="Should Be Skipped", host="10.0.0.1")
|
|
|
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=False)
|
|
|
|
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
mock_read.assert_not_called()
|
|
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 0, "No readings should be inserted when polling is disabled"
|
|
|
|
|
|
def test_poll_all_skipped_via_env_var(poll_db, monkeypatch):
|
|
"""MODBUS_POLLING_ENABLED=false env var disables polling via Settings override."""
|
|
monkeypatch.setenv("MODBUS_POLLING_ENABLED", "false")
|
|
# Build settings after env var is set.
|
|
bootstrap = Settings(_env_file=None, app_database_url="sqlite:///:memory:")
|
|
|
|
assert bootstrap.modbus_polling_enabled is False, (
|
|
"MODBUS_POLLING_ENABLED=false should yield modbus_polling_enabled=False"
|
|
)
|
|
|
|
with Session(poll_db) as session:
|
|
_make_device(session, friendly_name="Env-gated Meter", host="10.0.0.1")
|
|
|
|
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
mock_read.assert_not_called()
|
|
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Per-device interval: skip if recently polled, poll if interval elapsed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_all_respects_per_device_interval(poll_db):
|
|
"""Devices polled more recently than poll_interval_s are skipped; others are polled."""
|
|
now = datetime.now(UTC)
|
|
|
|
with Session(poll_db) as session:
|
|
# Device polled just 1 second ago with a 30-second interval → should be SKIPPED.
|
|
d_recent = _make_device(
|
|
session,
|
|
friendly_name="Recent Meter",
|
|
host="10.0.0.1",
|
|
poll_interval_s=30,
|
|
last_poll_at=now - timedelta(seconds=1),
|
|
)
|
|
# Device polled 60 seconds ago with a 30-second interval → should be POLLED.
|
|
d_due = _make_device(
|
|
session,
|
|
friendly_name="Due Meter",
|
|
host="10.0.0.2",
|
|
poll_interval_s=30,
|
|
last_poll_at=now - timedelta(seconds=60),
|
|
)
|
|
# Device never polled before → should be POLLED.
|
|
d_new = _make_device(
|
|
session,
|
|
friendly_name="New Meter",
|
|
host="10.0.0.3",
|
|
poll_interval_s=30,
|
|
last_poll_at=None,
|
|
)
|
|
|
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
|
|
|
with (
|
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
|
):
|
|
mock_profile = MagicMock()
|
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
|
mock_load.return_value = mock_profile
|
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
|
mock_decode.return_value = {"voltage": 230.2}
|
|
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 2, (
|
|
f"Expected 2 readings (due + new), got {len(readings)}: "
|
|
f"{[r.device_id for r in readings]}"
|
|
)
|
|
polled_device_ids = {r.device_id for r in readings}
|
|
assert d_due.id in polled_device_ids, "Due meter should have been polled"
|
|
assert d_new.id in polled_device_ids, "New meter should have been polled"
|
|
assert d_recent.id not in polled_device_ids, "Recent meter should have been skipped"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. poll_device returns None on failure, does not propagate exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_device_returns_none_on_driver_failure(poll_db):
|
|
"""poll_device swallows driver exceptions and returns None."""
|
|
with Session(poll_db) as session:
|
|
device = _make_device(session, friendly_name="Failing Device", host="10.0.0.9")
|
|
|
|
with (
|
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
|
):
|
|
mock_profile = MagicMock()
|
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
|
mock_load.return_value = mock_profile
|
|
mock_read.side_effect = RuntimeError("Simulated driver failure")
|
|
|
|
# Must NOT raise.
|
|
result = poll_device(session, device)
|
|
|
|
assert result is None, "poll_device should return None on failure"
|
|
|
|
session.expire_all()
|
|
fetched = session.get(ModbusDevice, device.id)
|
|
assert fetched.last_poll_ok is False
|
|
assert fetched.last_poll_at is not None
|
|
|
|
# No reading row was created.
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 0
|
|
|
|
|
|
def test_poll_device_returns_reading_on_success(poll_db):
|
|
"""poll_device returns the inserted ModbusReading on success."""
|
|
with Session(poll_db) as session:
|
|
device = _make_device(session, friendly_name="Success Device", host="10.0.0.5")
|
|
|
|
with (
|
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
|
):
|
|
mock_profile = MagicMock()
|
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
|
mock_load.return_value = mock_profile
|
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
|
mock_decode.return_value = KNOWN_PAYLOAD
|
|
|
|
result = poll_device(session, device)
|
|
|
|
assert result is not None, "poll_device should return a ModbusReading on success"
|
|
assert isinstance(result, ModbusReading)
|
|
assert result.device_id == device.id
|
|
assert result.payload == KNOWN_PAYLOAD
|
|
|
|
session.expire_all()
|
|
fetched = session.get(ModbusDevice, device.id)
|
|
assert fetched.last_poll_ok is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Disabled devices are not polled
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_all_skips_disabled_devices(poll_db):
|
|
"""Devices with enabled=False must not be polled."""
|
|
with Session(poll_db) as session:
|
|
_make_device(session, friendly_name="Disabled Meter", host="10.0.0.1", enabled=False)
|
|
|
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
|
|
|
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
mock_read.assert_not_called()
|
|
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 0, "Disabled devices must not produce readings"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Rollback on commit failure: no reading survives a failed poll
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_poll_device_commit_failure_leaves_no_reading(poll_db):
|
|
"""When session.commit() raises after session.add(reading), the reading must NOT persist.
|
|
|
|
Scenario (cross-device): two devices, device A's success-branch commit fails,
|
|
device B's poll succeeds normally. Expected outcome:
|
|
- 1 reading row total (device B only).
|
|
- device A: last_poll_ok=False, no reading row.
|
|
- device B: last_poll_ok=True, 1 reading row.
|
|
|
|
This guards against the "zombie reading" bug where a pending ModbusReading
|
|
added in the success branch (before commit failed) survived in the session
|
|
and was flushed by device B's subsequent commit.
|
|
"""
|
|
with Session(poll_db) as session:
|
|
d_fail = _make_device(session, friendly_name="Commit-Fail Meter", host="10.0.0.1")
|
|
d_ok = _make_device(session, friendly_name="OK Meter", host="10.0.0.2")
|
|
|
|
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
|
|
|
|
# Track how many times commit() is called so we can make only the
|
|
# first call (device A's success-branch commit) raise while letting
|
|
# the subsequent calls (device A's failure-state commit, device B's
|
|
# success commit) succeed normally.
|
|
original_commit = session.commit
|
|
commit_call_count = {"n": 0}
|
|
|
|
def patched_commit():
|
|
commit_call_count["n"] += 1
|
|
if commit_call_count["n"] == 1:
|
|
raise RuntimeError("Simulated commit failure (DB write error)")
|
|
return original_commit()
|
|
|
|
with (
|
|
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
|
|
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
|
|
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
|
|
patch.object(session, "commit", side_effect=patched_commit),
|
|
):
|
|
mock_profile = MagicMock()
|
|
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
|
|
mock_load.return_value = mock_profile
|
|
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
|
|
mock_decode.return_value = KNOWN_PAYLOAD
|
|
|
|
# Must not raise despite the first commit failing.
|
|
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
|
|
|
|
# Only device B (OK Meter) should have a reading — device A's reading
|
|
# must have been rolled back and must NOT have been committed via
|
|
# device B's subsequent commit (the "zombie reading" scenario).
|
|
readings = session.execute(select(ModbusReading)).scalars().all()
|
|
assert len(readings) == 1, (
|
|
f"Expected exactly 1 reading (OK Meter only), got {len(readings)}: "
|
|
f"device_ids={[r.device_id for r in readings]}"
|
|
)
|
|
assert readings[0].device_id == d_ok.id, (
|
|
"The surviving reading must belong to the OK Meter, not the commit-fail device"
|
|
)
|
|
|
|
session.expire_all()
|
|
fetched_fail = session.get(ModbusDevice, d_fail.id)
|
|
fetched_ok = session.get(ModbusDevice, d_ok.id)
|
|
|
|
assert fetched_fail.last_poll_ok is False, (
|
|
"Commit-fail device must be marked last_poll_ok=False"
|
|
)
|
|
assert fetched_ok.last_poll_ok is True, (
|
|
"OK Meter must be marked last_poll_ok=True"
|
|
)
|