from datetime import datetime from fam_edge.dsm_motion_client import DsmMotionClient def _cfg(**overrides): base = { "enabled": True, "host": "192.168.50.64", "port": 5000, "account": "ericwyuan", "password": "secret", "camera_id": 2, "min_motion_seconds": 0, "timeout_sec": 5, } base.update(overrides) return base class _FakeResp: def __init__(self, payload): self._payload = payload def json(self): return self._payload def _events_payload(events, ds_id="0"): return {"success": True, "data": {ds_id: events}} def test_disabled_config_always_returns_none(): c = DsmMotionClient(_cfg(enabled=False)) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None def test_missing_credentials_returns_none(): c = DsmMotionClient(_cfg(account="", password="")) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None def test_login_failure_returns_none(monkeypatch): def fake_get(url, params=None, timeout=None): return _FakeResp({"success": False, "error": {"code": 400}}) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg()) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None def test_login_network_exception_returns_none(monkeypatch): def fake_get(url, params=None, timeout=None): raise ConnectionError("boom") monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg()) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is None def test_no_motion_events_returns_false(monkeypatch): calls = {"n": 0} def fake_get(url, params=None, timeout=None): calls["n"] += 1 if params.get("api") == "SYNO.API.Auth": return _FakeResp({"success": True, "data": {"sid": "abc"}}) return _FakeResp(_events_payload([])) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg()) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is False def test_motion_event_present_returns_true(monkeypatch): def fake_get(url, params=None, timeout=None): if params.get("api") == "SYNO.API.Auth": return _FakeResp({"success": True, "data": {"sid": "abc"}}) return _FakeResp(_events_payload( [{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 12}])) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg()) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True def test_events_grouped_by_ds_id_not_camera_id(monkeypatch): """核心场景: 响应按 ds_id 分组(单机固定 "0"),不是按 camera_id 分组—— 错把 camera_id 当 dict key 去取会永远拿到空列表。""" def fake_get(url, params=None, timeout=None): if params.get("api") == "SYNO.API.Auth": return _FakeResp({"success": True, "data": {"sid": "abc"}}) return _FakeResp(_events_payload( [{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5}], ds_id="0")) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg(camera_id=2)) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True def test_events_for_other_camera_ignored(monkeypatch): def fake_get(url, params=None, timeout=None): if params.get("api") == "SYNO.API.Auth": return _FakeResp({"success": True, "data": {"sid": "abc"}}) return _FakeResp(_events_payload( [{"camera_id": 99, "event_type": 10, "start_time": 1000, "duration": 999}])) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg(camera_id=2)) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is False def test_total_motion_below_threshold_is_treated_as_no_motion(monkeypatch): def fake_get(url, params=None, timeout=None): if params.get("api") == "SYNO.API.Auth": return _FakeResp({"success": True, "data": {"sid": "abc"}}) return _FakeResp(_events_payload( [{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 1}])) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg(min_motion_seconds=3)) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is False def test_total_motion_meeting_threshold_counts_as_motion(monkeypatch): def fake_get(url, params=None, timeout=None): if params.get("api") == "SYNO.API.Auth": return _FakeResp({"success": True, "data": {"sid": "abc"}}) return _FakeResp(_events_payload( [{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 2}, {"camera_id": 2, "event_type": 10, "start_time": 1100, "duration": 2}])) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg(min_motion_seconds=3)) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True def test_expired_session_relogins_and_retries_once(monkeypatch): """session 过期(错误码 106)时应该重新登录一次再查,而不是直接放弃。""" state = {"logins": 0, "queries": 0} def fake_get(url, params=None, timeout=None): if params.get("api") == "SYNO.API.Auth": state["logins"] += 1 return _FakeResp({"success": True, "data": {"sid": f"sid{state['logins']}"}}) state["queries"] += 1 if state["queries"] == 1: return _FakeResp({"success": False, "error": {"code": 106}}) return _FakeResp(_events_payload( [{"camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5}])) monkeypatch.setattr("fam_edge.dsm_motion_client.requests.get", fake_get) c = DsmMotionClient(_cfg()) assert c.has_motion_in_range(datetime(2026, 8, 22), 1800) is True assert state["logins"] == 2 assert state["queries"] == 2 def test_env_var_credentials_resolved(): import os os.environ["TEST_DSM_ACCOUNT_XYZ"] = "realaccount" try: c = DsmMotionClient(_cfg(account="${TEST_DSM_ACCOUNT_XYZ}")) assert c.account == "realaccount" finally: del os.environ["TEST_DSM_ACCOUNT_XYZ"]