Skip to content

Commit

Permalink
Add tests for client & make minor adjustments
Browse files Browse the repository at this point in the history
  • Loading branch information
wbyoung committed Jun 22, 2024
1 parent 11901cc commit 8ced4b9
Show file tree
Hide file tree
Showing 11 changed files with 599 additions and 5 deletions.
21 changes: 17 additions & 4 deletions custom_components/watersmart/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import aiohttp
from bs4 import BeautifulSoup

ACCOUNT_NUMBER_RE = re.compile(r"[\d-]+")
ACCOUNT_NUMBER_RE = re.compile(r"^[\d-]+$")


def _authenticated(func):
Expand All @@ -29,6 +29,10 @@ def __init__(self, errors=None) -> None:
self._errors = errors


class ScrapeError(Exception):
"""Scrape Error."""


class WaterSmartClient:
"""WaterSmart Client."""

Expand Down Expand Up @@ -94,9 +98,12 @@ async def _authenticate(self) -> None:
if len(errors):
raise AuthenticationError(errors)

account = soup.find(id="account-navigation")
account_number_title = account.find(
lambda node: node.string == "Account Number"
account = _assert_node(
soup.find(id="account-navigation"), "Missing #account-navigation"
)
account_number_title = _assert_node(
account.find(lambda node: node.get_text(strip=True) == "Account Number"),
"Missing tag with string content `Account Number` under #account-navigation",
)
account_section = account_number_title.parent
account_number_title.extract()
Expand All @@ -107,3 +114,9 @@ async def _authenticate(self) -> None:
account_number = None

self._account_number = account_number


def _assert_node(node, message):
if not node:
raise ScrapeError(message)
return node
98 changes: 98 additions & 0 deletions script/cleanup-fixtures
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env python

import json
from pathlib import Path
from bs4 import BeautifulSoup, NavigableString


class JSONResponseAnonymizer:
"""JSON response anonymizer to shorten and prettify response object."""

FIXTURES = [
"realtime_api_response.json",
]

def run(self):
for fixture in self.FIXTURES:
path = Path(__file__).parent.parent.joinpath("tests/fixtures", fixture)
text = path.read_text()
obj = json.loads(text)
obj = self._shorten(obj)
text = json.dumps(obj, indent=4)
path.write_text(text)

def _shorten(self, obj):
obj["data"]["series"] = obj["data"]["series"][-4:]

return obj


class WebPageAnonymizer:
"""Web page anonymizer to remove personal info, shorten, and prettify."""

FIXTURES = [
"login_success.html",
"login_error.html",
]

def run(self):
for fixture in self.FIXTURES:
path = Path(__file__).parent.parent.joinpath("tests/fixtures", fixture)
html = path.read_text()
soup = self._shorten(BeautifulSoup(html, "html.parser"))
account = soup.find(id="account-navigation")

if account:
address = account.find(lambda tag: tag.get("notranslate") == "")
address.string = " 123 N Main St,\n Bend OR 97701"
account_number = soup.find("var")
account_number.string = "1234567-8900"

soup.smooth()

path.write_text(soup.prettify())

def _shorten(self, doc):
stack = [doc]

while len(stack):
node = stack.pop()

if self._contains_target(node) or self._is_target(node):
if not self._is_target(node):
stack.extend(node.contents)
else:
node.extract()

return doc

def _contains_target(self, node):
is_string = isinstance(node, NavigableString)
has_account = False
has_errors = False

if not is_string:
has_account = node.find(id="account-navigation") is not None
has_errors = len(node.select(".error-message")) > 0

return has_account or has_errors

def _is_target(self, node):
is_string = isinstance(node, NavigableString)
is_account = False
is_errors = False

if not is_string:
is_account = node.get("id") == "account-navigation"
is_errors = "error-message" in node.get("class", "")

return is_account or is_errors


def main():
WebPageAnonymizer().run()
JSONResponseAnonymizer().run()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ norecursedirs = .git
addopts =
--strict
--cov=custom_components
--cov-branch
--cov-report=term
--cov-report=html
asyncio_mode = auto
Expand Down
61 changes: 61 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,70 @@
"""Fixtures for testing."""

from unittest.mock import AsyncMock, patch
from pathlib import Path

# from asynctest import CoroutineMock, MagicMock, patch
# import aiohttp
import json
import pytest
from typing import Generator

FIXTURES_DIR = Path(__file__).parent.joinpath("fixtures")


class FixtureLoader:
"""Fixture loader."""

def __getitem__(self, name):
return self.__getattr__(name)

def __getattr__(self, name):
name, ext = name.rsplit("_", 1)

return FIXTURES_DIR.joinpath(f"{name}.{ext}").read_text()


@pytest.fixture
def fixture_loader():
return FixtureLoader()
return FIXTURES_DIR.joinpath("login_success.html").read_text()


@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable custom integrations."""
return


class MockAiohttpResponse:
def __init__(self, text="", json={}, status=200):
self.text = AsyncMock(return_value="", spec="aiohttp.ClientResponse.text")

async def json(self):
return json.loads(await self.text())


@pytest.fixture
def mock_aiohttp_session() -> Generator[dict[str, AsyncMock], None, None]:
with patch("aiohttp.ClientSession", autospec=True) as mock_session:
session = mock_session.return_value
session.get = AsyncMock(
return_value=MockAiohttpResponse(), spec="aiohttp.ClientSession.get"
)
session.put = AsyncMock(
return_value=MockAiohttpResponse(), spec="aiohttp.ClientSession.put"
)
session.post = AsyncMock(
return_value=MockAiohttpResponse(), spec="aiohttp.ClientSession.post"
)
session.delete = AsyncMock(
return_value=MockAiohttpResponse(), spec="aiohttp.ClientSession.delete"
)
session.options = AsyncMock(
return_value=MockAiohttpResponse(), spec="aiohttp.ClientSession.options"
)
session.patch = AsyncMock(
return_value=MockAiohttpResponse(), spec="aiohttp.ClientSession.patch"
)

yield session
73 changes: 73 additions & 0 deletions tests/fixtures/account_number_unmatchable.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<html>
<body class="portal desktop responsive">
<div class="modal-dialog" name="feedback-dialog">
<div class="modal-body">
<div class="feedback-body clearfix">
<div class="the-form">
<form action="/index.php/rest/v1/Feedback/submit?" id="feedback-form" method="post">
<div class="standard-form">
<ol class="form-rows ui form">
<li class="clearfix">
<div class="ui input field">
<div class="error-message phone_number" id="FeedbackFormModel_phone_number_em_" style="display:none">
</div>
</div>
</li>
<li class="clearfix">
<div class="ui input field">
<div class="error-message subject" id="FeedbackFormModel_subject_em_" style="display:none">
</div>
</div>
</li>
<li class="clearfix top-label">
<div class="ui field">
<div class="error-message message" id="FeedbackFormModel_message_em_" style="display:none">
</div>
</div>
</li>
</ol>
</div>
</form>
</div>
</div>
</div>
</div>
<nav id="sidebar-nav">
<div class="portal-nav sidebar">
<div class="linked-accounts">
<div class="content">
<div class="standard-form">
<div class="error-message">
</div>
</div>
</div>
</div>
</div>
</nav>
<div id="main-wrapper">
<div id="account-navigation">
<div class="width-container">
<span>
<div class="title">
Service Address
</div>
<div notranslate="">
123 N Main St,
Bend OR 97701
</div>
</span>
<span>
<div class="title">
Account Number
</div>
<div>
<var>
1234567-890A
</var>
</div>
</span>
</div>
</div>
</div>
</body>
</html>
19 changes: 19 additions & 0 deletions tests/fixtures/login_error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<html>
<body class="desktop landing unknown">
<div class="outer-container">
<div class="content">
<div class="landing-widgets standard">
<div class="main-widget">
<div class="auth">
<form action="/index.php/welcome/login?forceEmail=1" id="loginForm" method="post">
<div class="error-message">
Sorry, we didn’t recognize that email and password combination. Please try again.
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
73 changes: 73 additions & 0 deletions tests/fixtures/login_structure_change_failure.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<html>
<body class="portal desktop responsive">
<div class="modal-dialog" name="feedback-dialog">
<div class="modal-body">
<div class="feedback-body clearfix">
<div class="the-form">
<form action="/index.php/rest/v1/Feedback/submit?" id="feedback-form" method="post">
<div class="standard-form">
<ol class="form-rows ui form">
<li class="clearfix">
<div class="ui input field">
<div class="error-message phone_number" id="FeedbackFormModel_phone_number_em_" style="display:none">
</div>
</div>
</li>
<li class="clearfix">
<div class="ui input field">
<div class="error-message subject" id="FeedbackFormModel_subject_em_" style="display:none">
</div>
</div>
</li>
<li class="clearfix top-label">
<div class="ui field">
<div class="error-message message" id="FeedbackFormModel_message_em_" style="display:none">
</div>
</div>
</li>
</ol>
</div>
</form>
</div>
</div>
</div>
</div>
<nav id="sidebar-nav">
<div class="portal-nav sidebar">
<div class="linked-accounts">
<div class="content">
<div class="standard-form">
<div class="error-message">
</div>
</div>
</div>
</div>
</div>
</nav>
<div id="main-wrapper">
<div id="account-details"><!-- note that this id was changed -->
<div class="width-container">
<span>
<div class="title">
Service Address
</div>
<div notranslate="">
123 N Main St,
Bend OR 97701
</div>
</span>
<span>
<div class="title">
Account Number
</div>
<div>
<var>
1234567-8900
</var>
</div>
</span>
</div>
</div>
</div>
</body>
</html>
Loading

0 comments on commit 8ced4b9

Please sign in to comment.