Files
solar-relay/tests/test_status.py
T
franzz 89621bc7c3
Deploy Solar Relay / deploy (push) Successful in 1m2s
Simplify status interface
2026-11-02 23:14:13 +01:00

280 lines
14 KiB
Python

from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
import json
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, Request, build_opener
from zoneinfo import ZoneInfo
from solar_relay.config import Config, CollectorConfig, HttpConfig, InverterConfig, UploadConfig, utc_text
from solar_relay.coverage import daily_coverage, sample_coverage
from solar_relay.http import NoRedirects
from solar_relay.status import StatusServer
from solar_relay.storage import Store
from tests.test_relay import sample, server
def observed(when):
value = sample()
value["observed_at"] = utc_text(when)
value["source_timestamps"] = {source: value["observed_at"] for source in ("powerflow", "meter", "boiler")}
return value
def rows_for_day(day):
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
end = start + timedelta(days=1)
first, last = round(start.timestamp() * 1000), round(end.timestamp() * 1000)
return [(stamp, stamp, 1, stamp, 1, stamp, 1) for stamp in range(first, last + 1, 300_000)], end
class CoverageTests(unittest.TestCase):
def test_full_day_includes_all_288_intervals(self):
rows, end = rows_for_day("2026-10-30")
result = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))
yesterday = result[1]
self.assertEqual(yesterday["expected"], 288)
self.assertEqual(yesterday["complete"], 288)
self.assertEqual(yesterday["status"], "complete")
self.assertEqual(result[0]["elapsed"], 1)
self.assertEqual(result[0]["slots"][0], 3)
self.assertTrue(all(mask == -1 for mask in result[0]["slots"][1:]))
def test_successful_2305_extraction_is_green_at_2308(self):
zone = ZoneInfo("Europe/Zurich")
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
stamp = round(now.replace(minute=5).timestamp() * 1000)
day = daily_coverage([(stamp, stamp, 1, stamp, 1, stamp, 1)], zone.key, 1, now)[0]
self.assertEqual(day["slots"][277], 3)
self.assertEqual(day["slots"][278], -1)
self.assertEqual(day["complete"], 1)
self.assertEqual(day["energy_complete"], 1)
self.assertEqual(day["boiler_complete"], 1)
self.assertEqual(day["elapsed"], 278)
self.assertEqual(day["missing"], 277)
def test_current_slot_without_extraction_stays_pending_until_it_ends(self):
zone = ZoneInfo("Europe/Zurich")
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
day = daily_coverage([], zone.key, 1, now)[0]
self.assertEqual(day["slots"][277], -1)
self.assertEqual(day["elapsed"], 277)
self.assertEqual(day["missing"], 277)
after = daily_coverage([], zone.key, 1, now.replace(minute=10))[0]
self.assertEqual(after["slots"][277], 0)
self.assertEqual(after["slots"][278], -1)
self.assertEqual(after["missing"], 278)
def test_partial_and_failed_current_extractions_are_shown_immediately(self):
zone = ZoneInfo("Europe/Zurich")
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
stamp = round(now.replace(minute=5).timestamp() * 1000)
for energy, boiler, expected_mask in [(1, 0, 1), (0, 1, 2), (0, 0, 0)]:
with self.subTest(energy=energy, boiler=boiler):
day = daily_coverage([(stamp, stamp, energy, stamp, 0, stamp, boiler)], zone.key, 1, now)[0]
self.assertEqual(day["slots"][277], expected_mask)
self.assertEqual(day["elapsed"], 278)
self.assertEqual(day["complete"], 0)
def test_current_extraction_at_midnight_is_counted_without_waiting(self):
zone = ZoneInfo("Europe/Zurich")
now = datetime(2026, 11, 2, 0, 0, 10, tzinfo=zone)
stamp = round(now.replace(second=0).timestamp() * 1000)
day = daily_coverage([(stamp, stamp, 1, stamp, 0, stamp, 1)], zone.key, 1, now)[0]
self.assertEqual(day["slots"][0], 3)
self.assertEqual(day["elapsed"], 1)
self.assertEqual(day["complete"], 1)
self.assertEqual(day["missing"], 0)
self.assertTrue(all(mask == -1 for mask in day["slots"][1:]))
def test_dst_days_expect_276_and_300_intervals(self):
for date, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
with self.subTest(date=date):
rows, end = rows_for_day(date)
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["expected"], expected)
self.assertEqual(yesterday["complete"], expected)
def test_missing_five_minute_extractions_remain_missing(self):
rows, end = rows_for_day("2026-10-30")
del rows[10:13]
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["complete"], 285)
self.assertEqual(yesterday["missing"], 3)
self.assertEqual(yesterday["status"], "partial")
def test_energy_failure_does_not_erase_boiler_coverage(self):
rows, end = rows_for_day("2026-10-30")
stamp = rows[1][0]
rows[1] = (stamp, stamp, 0, stamp, 1, stamp, 1)
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["slots"][1], 2)
self.assertEqual(yesterday["energy_complete"], 287)
self.assertNotIn("meter_complete", yesterday)
self.assertEqual(yesterday["boiler_complete"], 288)
self.assertEqual(yesterday["missing"], 1)
def test_missing_meter_readings_do_not_affect_visible_completeness(self):
rows, end = rows_for_day("2026-10-30")
rows = [(observed, power_ms, power_ok, meter_ms, 0, boiler_ms, boiler_ok)
for observed, power_ms, power_ok, meter_ms, meter_ok, boiler_ms, boiler_ok in rows]
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
self.assertEqual(yesterday["complete"], 288)
self.assertEqual(yesterday["status"], "complete")
self.assertEqual(set(yesterday["slots"]), {3})
self.assertNotIn("meter_complete", yesterday)
def test_source_errors_stale_readings_and_false_boiler_state(self):
when = datetime(2026, 10, 30, tzinfo=timezone.utc)
value = observed(when)
value["readings"]["boiler_enabled"] = False
self.assertEqual(sample_coverage(value)[-1], 1)
value["errors"] = {"boiler": "unavailable"}
self.assertEqual(sample_coverage(value)[-1], 0)
value["source_timestamps"]["powerflow"] = utc_text(when - timedelta(seconds=120))
self.assertEqual(sample_coverage(value)[4], 0)
def test_parquet_coverage_uses_the_selected_source_and_stored_extractions(self):
with tempfile.TemporaryDirectory() as directory:
archive = Store(Path(directory) / "archive")
start = datetime(2026, 10, 30, tzinfo=ZoneInfo("Europe/Zurich"))
for seconds in range(0, 301, 30):
value = observed(start + timedelta(seconds=seconds))
archive.add(value)
result = archive.coverage(value["source_id"], "Europe/Zurich", 1, start + timedelta(days=1, minutes=3))
self.assertEqual(result[1]["complete"], 2)
other = archive.coverage("another-source", "Europe/Zurich", 1, start + timedelta(days=1, minutes=3))
self.assertEqual(other[1]["complete"], 0)
class StatusHttpTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.store = Store(Path(self.directory.name) / "archive")
self.config = Config(CollectorConfig("test-house", self.store.path), InverterConfig(), UploadConfig(), HttpConfig(port=0))
self.status = StatusServer(self.config, self.store)
self.status.start()
self.addCleanup(self.status.close)
self.base = "http://127.0.0.1:" + str(self.status.address[1])
self.http = build_opener(ProxyHandler({}), NoRedirects())
def get(self, path, *, method="GET"):
try:
result = self.http.open(Request(self.base + path, method=method), timeout=3)
except HTTPError as error:
result = error
with result:
return result.code, result.headers, result.read()
def test_page_assets_prefix_and_health_routes(self):
for path in ["/", "/solar-relay/", "/assets/status.css", "/solar-relay/assets/status.js"]:
status, _, body = self.get(path)
self.assertEqual(status, 200, path)
self.assertTrue(body)
status, headers, body = self.get("/solar-relay")
self.assertEqual(status, 308)
self.assertEqual(headers["Location"], "/solar-relay/")
self.assertEqual(json.loads(self.get("/solar-relay/healthz")[2])["status"], "ok")
def test_status_reports_extraction_and_backlog_without_raw_data_or_secrets(self):
value = observed(datetime.now(timezone.utc))
value["readings"]["boiler_enabled"] = False
self.store.add(value)
status, headers, body = self.get("/solar-relay/api/status?days=7")
result = json.loads(body)
self.assertEqual(status, 200)
self.assertEqual(headers["Cache-Control"], "no-store")
self.assertEqual(result["status"], "ok")
self.assertEqual(result["collector"]["last_attempt"], value["observed_at"])
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
self.assertEqual(result["archive"]["pending"], 1)
self.assertEqual(len(result["days"]), 8)
for forbidden in ["production_w", "raw", "database", self.directory.name]:
self.assertNotIn(forbidden, body.decode())
def test_source_errors_do_not_expose_exception_messages(self):
value = observed(datetime.now(timezone.utc))
value["errors"] = {"boiler": "secret canary must not appear in HTTP"}
self.store.add(value)
result = json.loads(self.get("/api/status")[2])
self.assertEqual(result["status"], "partial")
self.assertEqual(result["collector"]["source_errors"], ["boiler"])
self.assertNotIn("secret canary", json.dumps(result))
def test_meter_failure_does_not_change_current_status_or_last_success(self):
value = observed(datetime.now(timezone.utc))
value["errors"] = {"meter": "meter is unavailable"}
value["readings"]["grid_import_total_wh"] = None
value["readings"]["grid_export_total_wh"] = None
self.store.add(value)
result = json.loads(self.get("/api/status")[2])
self.assertEqual(result["status"], "ok")
self.assertEqual(result["collector"]["source_errors"], [])
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
self.assertNotIn("meter_complete", result["days"][0])
def test_latest_success_and_current_grid_cell_agree(self):
zone = ZoneInfo("Europe/Zurich")
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
value = observed(now.replace(minute=5))
self.store.add(value)
with unittest.mock.patch("solar_relay.status.datetime", wraps=datetime) as dates:
dates.now.return_value = now.astimezone(timezone.utc)
result = json.loads(self.get("/api/status?days=1")[2])
self.assertEqual(result["status"], "ok")
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
self.assertEqual(result["days"][0]["slots"][277], 3)
self.assertEqual(result["days"][0]["slots"][278], -1)
def test_only_explicit_read_only_routes_are_available(self):
for path in ["/config.toml", "/.env", "/data/archive/date=2026-11-01/samples.parquet", "/../config.toml", "/solar-relay/.git/config"]:
self.assertEqual(self.get(path)[0], 404, path)
self.assertEqual(self.get("/api/status?days=91")[0], 400)
self.assertEqual(self.get("/api/status?days=1&days=2")[0], 400)
self.assertEqual(self.get("/api/status", method="POST")[0], 405)
status, headers, body = self.get("/solar-relay/", method="HEAD")
self.assertEqual(status, 200)
self.assertGreater(int(headers["Content-Length"]), 0)
self.assertEqual(body, b"")
def test_run_command_starts_and_stops_the_http_listener(self):
with server() as (_, inverter):
with socket.socket() as free:
free.bind(("127.0.0.1", 0))
port = free.getsockname()[1]
path = Path(self.directory.name) / "config.toml"
path.write_text(f'[collector]\nsource_id="run-test"\narchive="run-archive"\n[inverter]\nbase_url="{inverter}"\n[http]\nport={port}\n')
# Keep the first scheduled poll in the future regardless of the real
# clock. Scheduling itself is exercised with a simulated clock separately.
entry = 'import sys; from solar_relay import cli; cli.next_poll_at = lambda now, interval: now + 300; sys.exit(cli.main())'
process = subprocess.Popen([sys.executable, "-c", entry, "--config", str(path), "run"], cwd=Path(__file__).resolve().parents[1], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
for attempt in range(30):
try:
with self.http.open(f"http://127.0.0.1:{port}/healthz", timeout=1) as response:
self.assertEqual(response.code, 200)
break
except URLError:
time.sleep(0.1)
else:
self.fail("Status listener did not start")
with self.http.open(f"http://127.0.0.1:{port}/api/status", timeout=1) as response:
snapshot = json.load(response)
self.assertEqual(snapshot["status"], "waiting")
self.assertEqual(snapshot["archive"]["samples"], 0)
process.terminate()
_, stderr = process.communicate(timeout=5)
self.assertEqual(process.returncode, 0, stderr)
self.assertEqual(Store(path.parent / "run-archive").status()["samples"], 0)
finally:
if process.poll() is None:
process.kill()
process.communicate(timeout=5)