Skip to content

Commit

Permalink
Merge pull request #187 from robinostlund/rate-limit
Browse files Browse the repository at this point in the history
Rate limit requests
  • Loading branch information
milkboy authored Apr 10, 2022
2 parents a1543e9 + eea4cc2 commit dc3e936
Show file tree
Hide file tree
Showing 11 changed files with 311 additions and 78 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ aiohttp
beautifulsoup4
lxml
pyjwt
pyrate_limiter>=2.6.0
16 changes: 14 additions & 2 deletions tests/fixtures/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import pytest
import pytest_asyncio
from aiohttp import CookieJar, ClientSession
from volkswagencarnet.vw_timer import TimerData

from .constants import timers_json_file, resource_path
from volkswagencarnet.vw_connection import Connection
from volkswagencarnet.vw_timer import TimerData
from volkswagencarnet.vw_utilities import json_loads
from .constants import timers_json_file, resource_path, timers_no_settings_json_file


@pytest_asyncio.fixture
Expand Down Expand Up @@ -49,3 +49,15 @@ async def getTimers(self, vin):
json = json_loads(f.read()).get("timer", {})
data = TimerData(**json)
return data


class TimersConnectionNoSettings(TimersConnection):
"""Connection that returns timers without basic settings."""

async def getTimers(self, vin):
"""Get timers data from backend."""
# test with a "real" response
with open(timers_no_settings_json_file) as f:
json = json_loads(f.read()).get("timer", {})
data = TimerData(**json)
return data
1 change: 1 addition & 0 deletions tests/fixtures/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
resource_path = os.path.join(current_path, "resources")
status_report_json_file = os.path.join(resource_path, "responses", "status.json")
timers_json_file = os.path.join(resource_path, "responses", "timer.json")
timers_no_settings_json_file = os.path.join(resource_path, "responses", "timer_without_settings.json")
42 changes: 42 additions & 0 deletions tests/fixtures/mock_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Mock HTTP server."""

import json
import socket
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread


class MockServerRequestHandler(BaseHTTPRequestHandler):
"""Mock HTTP handler."""

mock_responses = {"/ok": {"content": json.dumps([]), "code": 200}}

def do_GET(self):
"""Respond with something."""

if self.path in self.mock_responses:
self.send_response(self.mock_responses.get(self.path).get("code"))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
response_content = self.mock_responses.get(self.path).get("content")
self.wfile.write(response_content.encode("utf-8"))
return


def get_free_port():
"""Find a free port on localhost."""

s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM)
s.bind(("localhost", 0))
address, port = s.getsockname()
s.close()
return port


def start_mock_server(port):
"""Start the server."""

mock_server = HTTPServer(("localhost", port), MockServerRequestHandler)
mock_server_thread = Thread(target=mock_server.serve_forever)
mock_server_thread.setDaemon(True)
mock_server_thread.start()
38 changes: 38 additions & 0 deletions tests/fixtures/resources/responses/timer_without_settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"timer": {
"timersAndProfiles": {
"timerProfileList": {
"timerProfile": [
{
"timestamp": "2022-02-22T20:00:22Z",
"profileName": "Profile 1",
"profileID": "1",
"operationCharging": true,
"operationClimatisation": false,
"targetChargeLevel": "75",
"nightRateActive": true,
"nightRateTimeStart": "21:00",
"nightRateTimeEnd": "05:00",
"chargeMaxCurrent": "10"
}
]
},
"timerList": {
"timer": [
{
"timestamp": "2022-02-22T20:00:22Z",
"timerID": "3",
"profileID": "1",
"timerProgrammedStatus": "notProgrammed",
"timerFrequency": "cyclic",
"departureTimeOfDay": "07:55",
"departureWeekdayMask": "nnnnnyn"
}
]
}
},
"status": {
"timer": []
}
}
}
117 changes: 94 additions & 23 deletions tests/vw_connection_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"""Tests for main connection class."""
import sys
from aiohttp import client
from pyrate_limiter import BucketFullException, Duration, Limiter, RequestRate

from volkswagencarnet import vw_connection
from volkswagencarnet.vw_connection import Connection
from .fixtures.mock_server import get_free_port, start_mock_server

if sys.version_info >= (3, 8):
# This won't work on python versions less than 3.8
Expand All @@ -16,11 +20,39 @@ class IsolatedAsyncioTestCase(TestCase):


from io import StringIO
from unittest.mock import patch
from unittest.mock import patch, MagicMock

import pytest


class TwoVehiclesConnection(Connection):
"""Connection that return two vehicles."""

ALLOW_RATE_LIMIT_DELAY = False

# noinspection PyUnusedLocal
# noinspection PyMissingConstructor
def __init__(self, sess, username="", password="", **kwargs):
"""Init."""
super().__init__(session=sess, username=username, password=password)
self._jarCookie = MagicMock()

async def doLogin(self, tries=1):
"""No-op update."""
return True

async def update(self):
"""No-op update."""
return True

@property
def vehicles(self):
"""Return the vehicles."""
vehicle1 = vw_connection.Vehicle(None, "vin1")
vehicle2 = vw_connection.Vehicle(None, "vin2")
return [vehicle1, vehicle2]


@pytest.mark.skipif(condition=sys.version_info < (3, 8), reason="Test incompatible with Python < 3.8")
def test_clear_cookies(connection):
"""Check that we can clear old cookies."""
Expand All @@ -35,36 +67,16 @@ class CmdLineTest(IsolatedAsyncioTestCase):
class FailingLoginConnection:
"""This connection always fails login."""

# noinspection PyUnusedLocal
def __init__(self, sess, **kwargs):
"""Init."""
self._session = sess

# noinspection PyPep8Naming,PyMethodMayBeStatic
async def doLogin(self):
"""Failed login attempt."""
return False

class TwoVehiclesConnection:
"""Connection that return two vehicles."""

def __init__(self, sess, **kwargs):
"""Init."""
self._session = sess

async def doLogin(self):
"""No-op update."""
return True

async def update(self):
"""No-op update."""
return True

@property
def vehicles(self):
"""Return the vehicles."""
vehicle1 = vw_connection.Vehicle(None, "vin1")
vehicle2 = vw_connection.Vehicle(None, "vin2")
return [vehicle1, vehicle2]

@pytest.mark.asyncio
@patch.object(vw_connection.logging, "basicConfig")
@patch("volkswagencarnet.vw_connection.Connection", spec_set=vw_connection.Connection, new=FailingLoginConnection)
Expand Down Expand Up @@ -129,3 +141,62 @@ class SendCommandsTest(IsolatedAsyncioTestCase):
async def test_set_schedule(self):
"""Test set schedule."""
pass


class RateLimitTest(IsolatedAsyncioTestCase):
"""Test that rate limiting towards VW works."""

rate = RequestRate(1, 5 * Duration.SECOND)
limiter = Limiter(
rate,
)

async def rateLimitedFunction(self, url, vin=""):
"""Limit calls test function."""
async with self.limiter.ratelimit(f"{url}_{vin}"):
return ""

@pytest.mark.asyncio
@pytest.mark.skipif(condition=sys.version_info < (3, 9), reason="Test incompatible with Python < 3.9")
@patch("volkswagencarnet.vw_connection.Connection", spec_set=vw_connection.Connection, new=TwoVehiclesConnection)
async def test_rate_limit(self):
"""Test rate limiting functionality."""

from unittest.mock import AsyncMock

sess = AsyncMock()

vw_connection.ALLOW_RATE_LIMIT_DELAY = False
# noinspection PyArgumentList
conn = vw_connection.Connection(sess, "", "")

with (patch.object(conn, "_request", self.rateLimitedFunction), pytest.raises(BucketFullException)):
count = 0
for _ in range(2):
count += 1
res = await conn._request("GET", "foo")
assert count == 2
assert res == {"status_code": 429, "status_message": "Own rate limit exceeded."}

@pytest.mark.asyncio
@pytest.mark.skipif(condition=sys.version_info < (3, 9), reason="Test incompatible with Python < 3.9")
@patch("volkswagencarnet.vw_connection.Connection", spec_set=vw_connection.Connection, new=TwoVehiclesConnection)
async def test_rate_limited_get(self):
"""Test that rate limiting returns expected response."""

self.mock_server_port = get_free_port()
start_mock_server(self.mock_server_port)

sess = client.ClientSession(headers={"Connection": "close"})

# noinspection PyArgumentList
conn = vw_connection.Connection(sess, "", "")

limiter = Limiter(RequestRate(1, Duration.MINUTE))

with patch.object(conn, "limiter", limiter), pytest.raises(BucketFullException):
self.assertEqual([], await conn._request("GET", f"http://localhost:{self.mock_server_port}/ok"))
await conn._request("GET", f"http://localhost:{self.mock_server_port}/ok")
pytest.fail("Should have thrown exception...")

await sess.close()
18 changes: 18 additions & 0 deletions tests/vw_timer_test.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Depature timer tests."""
import copy
import datetime
from unittest import TestCase

from volkswagencarnet.vw_timer import TimerData, TimerProfile, parse_vw_datetime
from volkswagencarnet.vw_vehicle import Vehicle


class TimerTest(TestCase):
Expand Down Expand Up @@ -101,6 +103,22 @@ def test_timer_serialization(self):
self.assertTrue(timer.valid)
self.assertNotEqual(timer.json, timer.json_updated)

def test_timer_serialization_without_basic_settings(self):
"""Test de- and serialization of timers."""
newdata: dict = copy.deepcopy(self.data["timer"])
newdata["timersAndProfiles"]["timerBasicSetting"] = None
timer = TimerData(**newdata)
self.assertEqual({"timer": newdata}, timer.json)
self.assertTrue(timer.valid)
self.assertNotEqual(timer.json, timer.json_updated)

vehicle = Vehicle(None, "")
vehicle.attrs["timer"] = timer
self.assertFalse(vehicle.is_schedule_min_charge_level_supported)
self.assertFalse(vehicle.is_timer_basic_settings_supported)
self.assertFalse(vehicle.is_departure_timer2_supported)
self.assertTrue(vehicle.is_departure_timer3_supported)

def test_update_serialization(self):
"""Check that updating a timer sets correct attributes."""
timer = TimerData(**self.data["timer"])
Expand Down
13 changes: 12 additions & 1 deletion tests/vw_vehicle_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from volkswagencarnet.vw_timer import TimerData
from volkswagencarnet.vw_utilities import json_loads
from .fixtures.connection import TimersConnection
from .fixtures.connection import TimersConnection, TimersConnectionNoSettings
from .fixtures.constants import status_report_json_file, MOCK_VIN

if sys.version_info >= (3, 8):
Expand Down Expand Up @@ -226,6 +226,17 @@ async def test_get_schedule_not_supported(self):
with self.assertRaises(ValueError):
self.assertIsNone(vehicle.schedule(42))

async def test_get_schedule_no_basic_settings(self):
"""Test that not found schedule is unsupported."""
vehicle = Vehicle(conn=TimersConnectionNoSettings(None), url=MOCK_VIN)
vehicle._discovered = True

with patch.dict(vehicle._services, {"timerprogramming_v1": {"active": True}}):
await vehicle.get_timerprogramming()
self.assertFalse(vehicle.is_schedule_supported(42))
with self.assertRaises(ValueError):
self.assertIsNone(vehicle.schedule(42))

async def test_get_schedule_last_updated(self):
"""Test that not found schedule is unsupported."""
vehicle = Vehicle(conn=TimersConnection(None), url=MOCK_VIN)
Expand Down
Loading

0 comments on commit dc3e936

Please sign in to comment.