Skip to content

Commit 3911fb4

Browse files
author
BESS Solutions
committed
test: add 46 tests for DashboardAPI handlers and LUNADriver async methods
- Add test_dashboard_api_handlers.py: covers _check_auth, handle_status, handle_fleet, handle_carbon, handle_p2p, handle_version, handle_health, handle_schedule (cache hit/miss), start/stop lifecycle and is_available - Add test_luna2000_driver_async.py: covers read_telemetry (all fields), set_mode, set_charge_target_soc (valid/invalid), __aenter__/__aexit__ lifecycle, and Modbus error handling in _read_regs/_write_reg - Fix: REG_LUNA_TARGET_SOC confirmed as 47087 (adjacent to mode reg 47086) - Fix: datetime.utcnow() -> datetime.now(timezone.utc) in dashboard_api.py - Coverage: dashboard_api 52% -> ~85%, luna2000_driver 67% -> ~85% - Total suite: 321 passed (was 275)
1 parent 9b5cb47 commit 3911fb4

3 files changed

Lines changed: 569 additions & 1 deletion

File tree

src/interfaces/dashboard_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ async def handle_schedule(self, request: Any) -> Any:
283283
schedule = engine.compute(forecasts, current_soc_pct=self.state.soc_pct)
284284

285285
result = schedule.to_api_dict()
286-
result["computed_at"] = datetime.datetime.utcnow().isoformat() + "Z"
286+
result["computed_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
287287
result["predictor_method"] = forecasts[0].method if forecasts else "unknown"
288288

289289
# Cache
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""
2+
tests/test_dashboard_api_handlers.py
3+
======================================
4+
Tests for DashboardAPI HTTP handlers and lifecycle.
5+
6+
Uses lightweight request mocks — no real aiohttp server needed.
7+
Covers: _check_auth, handle_status, handle_fleet, handle_carbon,
8+
handle_p2p, handle_version, handle_health, handle_schedule,
9+
start/stop lifecycle, is_available property.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
from unittest.mock import AsyncMock, MagicMock
16+
17+
import pytest
18+
19+
from src.interfaces.dashboard_api import DashboardAPI, DashboardState
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# Helpers
24+
# ---------------------------------------------------------------------------
25+
26+
27+
def _make_request(auth_header: str = "", query: dict | None = None) -> MagicMock:
28+
"""Minimal aiohttp Request mock."""
29+
req = MagicMock()
30+
req.headers = {"Authorization": auth_header} if auth_header else {}
31+
if query is not None:
32+
req.rel_url.query = query
33+
else:
34+
req.rel_url.query = {}
35+
return req
36+
37+
38+
def _parse(response: MagicMock) -> dict:
39+
"""Extract JSON payload from a web.Response mock text."""
40+
return json.loads(response.text)
41+
42+
43+
# ---------------------------------------------------------------------------
44+
# _check_auth
45+
# ---------------------------------------------------------------------------
46+
47+
48+
class TestCheckAuth:
49+
@pytest.mark.asyncio
50+
async def test_no_api_key_always_passes(self):
51+
api = DashboardAPI(api_key="")
52+
req = _make_request()
53+
assert await api._check_auth(req) is True
54+
55+
@pytest.mark.asyncio
56+
async def test_correct_bearer_token_passes(self):
57+
api = DashboardAPI(api_key="secret123")
58+
req = _make_request(auth_header="Bearer secret123")
59+
assert await api._check_auth(req) is True
60+
61+
@pytest.mark.asyncio
62+
async def test_wrong_token_fails(self):
63+
api = DashboardAPI(api_key="secret123")
64+
req = _make_request(auth_header="Bearer wrong")
65+
assert await api._check_auth(req) is False
66+
67+
@pytest.mark.asyncio
68+
async def test_missing_header_fails(self):
69+
api = DashboardAPI(api_key="secret123")
70+
req = _make_request(auth_header="")
71+
assert await api._check_auth(req) is False
72+
73+
74+
# ---------------------------------------------------------------------------
75+
# handle_status
76+
# ---------------------------------------------------------------------------
77+
78+
79+
class TestHandleStatus:
80+
def _api(self) -> DashboardAPI:
81+
state = DashboardState(site_id="test-site")
82+
state.soc_pct = 75.0
83+
state.is_safe = True
84+
return DashboardAPI(state=state, api_key="")
85+
86+
@pytest.mark.asyncio
87+
async def test_status_returns_json_with_site_id(self):
88+
api = self._api()
89+
req = _make_request()
90+
resp = await api.handle_status(req)
91+
data = _parse(resp)
92+
assert data["site_id"] == "test-site"
93+
94+
@pytest.mark.asyncio
95+
async def test_status_unauthorized_with_key(self):
96+
api = DashboardAPI(api_key="tok")
97+
req = _make_request()
98+
resp = await api.handle_status(req)
99+
assert resp.status == 401
100+
101+
@pytest.mark.asyncio
102+
async def test_status_authorized_with_correct_key(self):
103+
state = DashboardState(site_id="s1")
104+
api = DashboardAPI(state=state, api_key="tok")
105+
req = _make_request(auth_header="Bearer tok")
106+
resp = await api.handle_status(req)
107+
data = _parse(resp)
108+
assert "telemetry" in data
109+
110+
111+
# ---------------------------------------------------------------------------
112+
# handle_fleet
113+
# ---------------------------------------------------------------------------
114+
115+
116+
class TestHandleFleet:
117+
@pytest.mark.asyncio
118+
async def test_fleet_returns_n_sites(self):
119+
state = DashboardState()
120+
state.fleet_n_sites = 7
121+
api = DashboardAPI(state=state, api_key="")
122+
resp = await api.handle_fleet(_make_request())
123+
data = _parse(resp)
124+
assert data["n_sites"] == 7
125+
126+
@pytest.mark.asyncio
127+
async def test_fleet_unauthorized_returns_401(self):
128+
api = DashboardAPI(api_key="secret")
129+
resp = await api.handle_fleet(_make_request())
130+
assert resp.status == 401
131+
132+
133+
# ---------------------------------------------------------------------------
134+
# handle_carbon
135+
# ---------------------------------------------------------------------------
136+
137+
138+
class TestHandleCarbon:
139+
@pytest.mark.asyncio
140+
async def test_carbon_contains_co2_key(self):
141+
state = DashboardState()
142+
state.co2_avoided_kg = 500.0
143+
api = DashboardAPI(state=state, api_key="")
144+
resp = await api.handle_carbon(_make_request())
145+
data = _parse(resp)
146+
assert data["co2_avoided_kg"] == pytest.approx(500.0)
147+
assert "methodology" in data
148+
149+
@pytest.mark.asyncio
150+
async def test_carbon_unauthorized_returns_401(self):
151+
api = DashboardAPI(api_key="tok")
152+
resp = await api.handle_carbon(_make_request())
153+
assert resp.status == 401
154+
155+
156+
# ---------------------------------------------------------------------------
157+
# handle_p2p
158+
# ---------------------------------------------------------------------------
159+
160+
161+
class TestHandleP2P:
162+
@pytest.mark.asyncio
163+
async def test_p2p_credits_minted(self):
164+
state = DashboardState()
165+
state.p2p_credits_minted = 10
166+
state.p2p_credits_kwh = 50.0
167+
api = DashboardAPI(state=state, api_key="")
168+
resp = await api.handle_p2p(_make_request())
169+
data = _parse(resp)
170+
assert data["credits_minted"] == 10
171+
172+
@pytest.mark.asyncio
173+
async def test_p2p_unauthorized_returns_401(self):
174+
api = DashboardAPI(api_key="tok")
175+
resp = await api.handle_p2p(_make_request())
176+
assert resp.status == 401
177+
178+
179+
# ---------------------------------------------------------------------------
180+
# handle_version (no auth required)
181+
# ---------------------------------------------------------------------------
182+
183+
184+
class TestHandleVersion:
185+
@pytest.mark.asyncio
186+
async def test_version_has_required_fields(self):
187+
api = DashboardAPI(api_key="tok") # no auth on this endpoint
188+
resp = await api.handle_version(_make_request())
189+
data = _parse(resp)
190+
assert "version" in data
191+
assert "build_date" in data
192+
assert "project" in data
193+
194+
@pytest.mark.asyncio
195+
async def test_version_no_auth_needed(self):
196+
"""Version endpoint does NOT require auth."""
197+
api = DashboardAPI(api_key="tok")
198+
resp = await api.handle_version(_make_request())
199+
# Should not return 401 — version is always public
200+
assert not hasattr(resp, "status") or resp.status != 401
201+
202+
203+
# ---------------------------------------------------------------------------
204+
# handle_health (no auth required)
205+
# ---------------------------------------------------------------------------
206+
207+
208+
class TestHandleHealth:
209+
@pytest.mark.asyncio
210+
async def test_health_ok_when_safe(self):
211+
state = DashboardState(site_id="hsite")
212+
state.is_safe = True
213+
api = DashboardAPI(state=state, api_key="")
214+
resp = await api.handle_health(_make_request())
215+
data = _parse(resp)
216+
assert data["status"] == "ok"
217+
218+
@pytest.mark.asyncio
219+
async def test_health_degraded_when_unsafe(self):
220+
state = DashboardState(site_id="hsite")
221+
state.is_safe = False
222+
api = DashboardAPI(state=state, api_key="")
223+
resp = await api.handle_health(_make_request())
224+
data = _parse(resp)
225+
assert data["status"] == "degraded"
226+
227+
228+
# ---------------------------------------------------------------------------
229+
# handle_schedule
230+
# ---------------------------------------------------------------------------
231+
232+
233+
class TestHandleSchedule:
234+
@pytest.mark.asyncio
235+
async def test_schedule_returns_hourly_schedule(self):
236+
state = DashboardState(site_id="sched-site")
237+
state.soc_pct = 55.0
238+
api = DashboardAPI(state=state, api_key="")
239+
req = _make_request(query={"capacity_kwh": "500", "max_power_kw": "250"})
240+
resp = await api.handle_schedule(req)
241+
data = _parse(resp)
242+
assert "hourly_schedule" in data
243+
assert len(data["hourly_schedule"]) == 24
244+
245+
@pytest.mark.asyncio
246+
async def test_schedule_unauthorized_returns_401(self):
247+
api = DashboardAPI(api_key="tok")
248+
req = _make_request(query={})
249+
resp = await api.handle_schedule(req)
250+
assert resp.status == 401
251+
252+
@pytest.mark.asyncio
253+
async def test_schedule_uses_cache_when_fresh(self):
254+
import time
255+
256+
state = DashboardState()
257+
state._schedule_dict = {"cached": True, "hourly_schedule": []}
258+
state.schedule_last_updated = time.time() - 10 # 10s ago → fresh
259+
api = DashboardAPI(state=state, api_key="")
260+
req = _make_request(query={})
261+
resp = await api.handle_schedule(req)
262+
data = _parse(resp)
263+
assert data.get("cached") is True
264+
265+
@pytest.mark.asyncio
266+
async def test_schedule_recomputes_when_stale(self):
267+
import time
268+
269+
state = DashboardState()
270+
state._schedule_dict = {"cached": True, "hourly_schedule": []}
271+
state.schedule_last_updated = time.time() - 1000 # stale
272+
state.soc_pct = 50.0
273+
api = DashboardAPI(state=state, api_key="")
274+
req = _make_request(query={"capacity_kwh": "1000"})
275+
resp = await api.handle_schedule(req)
276+
data = _parse(resp)
277+
# Fresh computation should return the proper structure
278+
assert "hourly_schedule" in data
279+
assert data.get("cached") is not True
280+
281+
282+
# ---------------------------------------------------------------------------
283+
# is_available property
284+
# ---------------------------------------------------------------------------
285+
286+
287+
class TestDashboardAPIAvailability:
288+
def test_is_available_returns_bool(self):
289+
api = DashboardAPI()
290+
assert isinstance(api.is_available, bool)
291+
292+
def test_is_available_true_when_aiohttp_installed(self):
293+
"""aiohttp is in requirements.txt — should be available in test env."""
294+
api = DashboardAPI()
295+
assert api.is_available is True
296+
297+
298+
# ---------------------------------------------------------------------------
299+
# Lifecycle: start / stop
300+
# ---------------------------------------------------------------------------
301+
302+
303+
class TestDashboardAPILifecycle:
304+
@pytest.mark.asyncio
305+
async def test_start_raises_if_no_aiohttp(self, monkeypatch):
306+
import src.interfaces.dashboard_api as mod
307+
308+
monkeypatch.setattr(mod, "_AIOHTTP_AVAILABLE", False)
309+
api = DashboardAPI()
310+
with pytest.raises(RuntimeError, match="aiohttp"):
311+
await api.start()
312+
313+
@pytest.mark.asyncio
314+
async def test_stop_when_runner_is_none_is_noop(self):
315+
api = DashboardAPI()
316+
api._runner = None
317+
await api.stop() # should not raise

0 commit comments

Comments
 (0)