|
| 1 | +"""Growatt reports rate limiting in the body, not as an HTTP status.""" |
| 2 | +import json |
| 3 | + |
| 4 | +import pytest |
| 5 | +import requests |
| 6 | + |
| 7 | +from growattServer import GrowattApi, GrowattRateLimitError |
| 8 | + |
| 9 | + |
| 10 | +def _response(payload=None, *, body=None, status=200): |
| 11 | + """Build a real Response -- the check parses the body itself.""" |
| 12 | + response = requests.Response() |
| 13 | + response.status_code = status |
| 14 | + if body is None: |
| 15 | + body = json.dumps(payload) |
| 16 | + response._content = body.encode() |
| 17 | + response.headers["Content-Type"] = "application/json" |
| 18 | + return response |
| 19 | + |
| 20 | + |
| 21 | +class _HookedSession: |
| 22 | + """A session stand-in that runs the response hooks, as requests does. |
| 23 | +
|
| 24 | + The 507 check lives in the hook now, so a plain MagicMock session would |
| 25 | + skip it and every test here would pass for the wrong reason. |
| 26 | + """ |
| 27 | + |
| 28 | + def __init__(self, hooks, response): |
| 29 | + self.hooks = hooks |
| 30 | + self.headers = {} |
| 31 | + self._response = response |
| 32 | + |
| 33 | + def _dispatch(self, *args, **kwargs): |
| 34 | + for hook in self.hooks["response"]: |
| 35 | + hook(self._response) |
| 36 | + return self._response |
| 37 | + |
| 38 | + get = _dispatch |
| 39 | + post = _dispatch |
| 40 | + |
| 41 | + |
| 42 | +def _api_returning(response): |
| 43 | + """An API whose every call answers with `response`, hooks included.""" |
| 44 | + api = GrowattApi() |
| 45 | + api.session = _HookedSession(api.session.hooks, response) |
| 46 | + return api |
| 47 | + |
| 48 | + |
| 49 | +def _api_with_login_body(body): |
| 50 | + """An API whose login answers with the given `back` payload.""" |
| 51 | + return _api_returning(_response({"back": body})) |
| 52 | + |
| 53 | + |
| 54 | +def test_login_raises_on_507_in_the_body(): |
| 55 | + """`success: false` + `msg: "507"` is the real lockout signal.""" |
| 56 | + api = _api_with_login_body({"success": False, "msg": "507"}) |
| 57 | + |
| 58 | + with pytest.raises(GrowattRateLimitError) as exc: |
| 59 | + api.login("user", "pass") |
| 60 | + |
| 61 | + assert exc.value.error_code == "507" |
| 62 | + |
| 63 | + |
| 64 | +def test_the_code_is_readable_off_the_exception(): |
| 65 | + """Callers branch on `error_code`, never on the message wording.""" |
| 66 | + api = _api_with_login_body({"success": False, "msg": "507"}) |
| 67 | + |
| 68 | + with pytest.raises(GrowattRateLimitError) as exc: |
| 69 | + api.login("user", "pass") |
| 70 | + |
| 71 | + assert exc.value.error_code == "507" |
| 72 | + assert str(exc.value) == "Growatt rate limit reached: [507]" |
| 73 | + |
| 74 | + |
| 75 | +def test_a_data_call_is_covered_too(): |
| 76 | + """Growatt rate limits per endpoint, so this cannot be login-only.""" |
| 77 | + api = _api_returning(_response({"back": {"success": False, "msg": "507"}})) |
| 78 | + |
| 79 | + with pytest.raises(GrowattRateLimitError): |
| 80 | + api.plant_list("user_1") |
| 81 | + |
| 82 | + |
| 83 | +def test_other_failures_are_left_alone(): |
| 84 | + """A wrong password must keep returning the dict, not raise.""" |
| 85 | + api = _api_with_login_body({"success": False, "msg": "501"}) |
| 86 | + |
| 87 | + result = api.login("user", "pass") |
| 88 | + |
| 89 | + assert result["success"] is False |
| 90 | + assert result["msg"] == "501" |
| 91 | + |
| 92 | + |
| 93 | +def test_numeric_msg_is_still_recognised(): |
| 94 | + """Defensive: the code is compared as a string.""" |
| 95 | + api = _api_with_login_body({"success": False, "msg": 507}) |
| 96 | + |
| 97 | + with pytest.raises(GrowattRateLimitError): |
| 98 | + api.login("user", "pass") |
| 99 | + |
| 100 | + |
| 101 | +def test_successful_login_is_unaffected(): |
| 102 | + """The happy path keeps its existing shape.""" |
| 103 | + api = _api_with_login_body({ |
| 104 | + "success": True, |
| 105 | + "user": {"id": "u1", "rightlevel": 1}, |
| 106 | + }) |
| 107 | + |
| 108 | + result = api.login("user", "pass") |
| 109 | + |
| 110 | + assert result["userId"] == "u1" |
| 111 | + assert result["userLevel"] == 1 |
| 112 | + |
| 113 | + |
| 114 | +def test_a_list_back_does_not_raise(): |
| 115 | + """`plant_list` answers with `back` as a list -- the guard must hold.""" |
| 116 | + api = _api_returning(_response({"back": [{"success": False, "msg": "507"}]})) |
| 117 | + |
| 118 | + assert api.plant_list("user_1") == [{"success": False, "msg": "507"}] |
| 119 | + |
| 120 | + |
| 121 | +def test_a_non_json_body_does_not_raise(): |
| 122 | + """The hook sees every response, including ones that are not JSON.""" |
| 123 | + response = _response(body="<html>maintenance</html>") |
| 124 | + response.headers["Content-Type"] = "text/html" |
| 125 | + api = _api_returning(response) |
| 126 | + |
| 127 | + for hook in api.session.hooks["response"]: |
| 128 | + hook(response) |
| 129 | + |
| 130 | + |
| 131 | +def test_a_bare_body_without_the_back_wrapper_is_checked(): |
| 132 | + """Not every endpoint wraps its payload, so read the top level too.""" |
| 133 | + api = _api_returning(_response({"success": False, "msg": "507"})) |
| 134 | + |
| 135 | + with pytest.raises(GrowattRateLimitError): |
| 136 | + api.plant_list("user_1") |
| 137 | + |
| 138 | + |
| 139 | +def test_http_errors_still_raise(): |
| 140 | + """The rate-limit check must not shadow raise_for_status.""" |
| 141 | + api = _api_returning(_response({"back": {"success": True}}, status=500)) |
| 142 | + |
| 143 | + with pytest.raises(requests.exceptions.HTTPError): |
| 144 | + api.plant_list("user_1") |
0 commit comments