From 2bfece1e5632231f57fc555835c072909efd320f Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Fri, 10 Dec 2021 18:05:49 -0600 Subject: [PATCH] add some client work --- .pre-commit-config.yaml | 2 +- noxfile.py | 12 +- poetry.lock | 65 +++++++++- pyproject.toml | 4 + src/healthchecks_io/__init__.py | 2 + src/healthchecks_io/client/__init__.py | 2 + src/healthchecks_io/client/_abstract.py | 146 ++++++++++++++++++++++ src/healthchecks_io/client/asyncclient.py | 86 +++++++++++++ src/healthchecks_io/client/exceptions.py | 13 ++ src/healthchecks_io/schemas/__init__.py | 1 + tests/client/test_async.py | 76 +++++++++++ tests/conftest.py | 8 ++ tests/schemas/test_badges.py | 17 +-- tests/schemas/test_integrations.py | 13 +- 14 files changed, 428 insertions(+), 19 deletions(-) create mode 100644 src/healthchecks_io/client/__init__.py create mode 100644 src/healthchecks_io/client/_abstract.py create mode 100644 src/healthchecks_io/client/asyncclient.py create mode 100644 src/healthchecks_io/client/exceptions.py create mode 100644 tests/client/test_async.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e727a9c..a3a7024 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: entry: flake8 language: system types: [python] - exclude: "tests/*" + exclude: "^(tests/*|noxfile.py)" require_serial: true - id: pyupgrade name: pyupgrade diff --git a/noxfile.py b/noxfile.py index 8a24af3..965a89b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -26,6 +26,7 @@ python_versions = ["3.10", "3.9", "3.8", "3.7"] nox.needs_version = ">= 2021.6.6" nox.options.sessions = ( "pre-commit", + "bandit", "safety", "mypy", "tests", @@ -139,13 +140,18 @@ def safety(session: Session) -> None: @session(python=python_versions) def mypy(session: Session) -> None: """Type-check using mypy.""" - args = session.posargs or ["src", "docs/conf.py"] + args = session.posargs or ["src"] session.install(".") session.install("mypy", "pytest") session.install(*mypy_type_packages) session.run("mypy", *args) - if not session.posargs: - session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py") + + +@session(python=python_versions[0]) +def bandit(session: Session) -> None: + """Run bandit security tests""" + args = session.posargs or ["-r", "./src"] + session.run("bandit", *args) @session(python=python_versions) diff --git a/poetry.lock b/poetry.lock index 769d141..e2d26b5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -752,6 +752,28 @@ toml = "*" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +name = "pytest-async" +version = "0.1.1" +description = "pytest-async - Run your coroutine in event loop without decorator" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pytest-asyncio" +version = "0.16.0" +description = "Pytest support for asyncio." +category = "dev" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +testing = ["coverage", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-cov" version = "3.0.0" @@ -767,6 +789,20 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-mock" +version = "3.6.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "tox", "pytest-asyncio"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -842,6 +878,17 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +[[package]] +name = "respx" +version = "0.19.0" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +httpx = ">=0.21.0" + [[package]] name = "restructuredtext-lint" version = "1.3.2" @@ -1231,7 +1278,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "062c8a09c8967fafd0410a702d9bdc17ef0a260965b65345c6058a3a134a05a3" +content-hash = "4609efb7758ecc2785e3e38e1b1bad140bd0311d253ffebd86b9e5d4e4d45766" [metadata.files] alabaster = [ @@ -1637,10 +1684,22 @@ pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] +pytest-async = [ + {file = "pytest_async-0.1.1-py3-none-any.whl", hash = "sha256:11cc41eef82592951d56c2bb9b0e6ab21b2f0f00663e78d95694a80d965be930"}, + {file = "pytest_async-0.1.1.tar.gz", hash = "sha256:0d6ffd3ebac2f3aa47d606dbae1984750268a89dc8caf4a908ba61c60299cdfd"}, +] +pytest-asyncio = [ + {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, + {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, +] pytest-cov = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] +pytest-mock = [ + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, +] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -1772,6 +1831,10 @@ requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] +respx = [ + {file = "respx-0.19.0-py2.py3-none-any.whl", hash = "sha256:1ac1cc99bf892ffd3e33108ae43d71d8309a58ac226965f4bd81ec055600f265"}, + {file = "respx-0.19.0.tar.gz", hash = "sha256:4a09e15803c7450d45303520ec528794c9fd77b05984263bc83b78aabbb39413"}, +] restructuredtext-lint = [ {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"}, ] diff --git a/pyproject.toml b/pyproject.toml index 90ae5c6..f30b363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,10 @@ furo = ">=2021.11.12" pytest-cov = "^3.0.0" types-croniter = "^1.0.3" types-pytz = "^2021.3.1" +pytest_async = "^0.1.1" +pytest-asyncio = "^0.16.0" +respx = "^0.19.0" +pytest-mock = "^3.6.1" [tool.coverage.paths] source = ["src", "*/site-packages"] diff --git a/src/healthchecks_io/__init__.py b/src/healthchecks_io/__init__.py index 5cf4a01..a9d1761 100644 --- a/src/healthchecks_io/__init__.py +++ b/src/healthchecks_io/__init__.py @@ -1 +1,3 @@ """Py Healthchecks.Io.""" + +VERSION = "0.1" diff --git a/src/healthchecks_io/client/__init__.py b/src/healthchecks_io/client/__init__.py new file mode 100644 index 0000000..8a97a7e --- /dev/null +++ b/src/healthchecks_io/client/__init__.py @@ -0,0 +1,2 @@ +"""healthchecks_io clients.""" +from .asyncclient import AsyncClient # noqa: F401 diff --git a/src/healthchecks_io/client/_abstract.py b/src/healthchecks_io/client/_abstract.py new file mode 100644 index 0000000..89ecd0a --- /dev/null +++ b/src/healthchecks_io/client/_abstract.py @@ -0,0 +1,146 @@ +from abc import ABC +from abc import abstractmethod +from json import dumps +from typing import Dict +from typing import List +from typing import Optional +from urllib.parse import parse_qsl +from urllib.parse import ParseResult +from urllib.parse import unquote +from urllib.parse import urlencode +from urllib.parse import urljoin +from urllib.parse import urlparse +from weakref import finalize + +from httpx import Client + +from healthchecks_io.schemas import checks + + +class AbstractClient(ABC): + """An abstract client class that can be implemented by client classes.""" + + def __init__( + self, + api_key: str, + api_url: Optional[str] = "https://healthchecks.io/api/", + api_version: Optional[int] = 1, + client: Optional[Client] = None, + ) -> None: + """An AbstractClient that other clients can implement. + + Args: + api_key (str): Healthchecks.io API key + api_url (Optional[str], optional): API URL. Defaults to "https://healthchecks.io/api/". + api_version (Optional[int], optional): Versiopn of the api to use. Defaults to 1. + client (Optional[Client], optional): A httpx.Client. If not + passed in, one will be created for this object. Defaults to None. + """ + self._api_key = api_key + self._client = client + if not api_url.endswith("/"): + api_url = f"{api_url}/" + self._api_url = urljoin(api_url, f"v{api_version}/") + self._finalizer = finalize(self, self._finalizer_method) + + @abstractmethod + def _finalizer_method(self): + """Finalizer method is called by weakref.finalize when the object is dereferenced to do cleanup of clients.""" + pass + + @abstractmethod + def get_checks(self, tags: Optional[List[str]]) -> List[checks.Check]: + """Calls the API's /checks/ endpoint to get a list of checks.""" + pass + + def _get_api_request_url( + self, path: str, params: Optional[Dict[str, str]] = None + ) -> str: + """Get a full request url for the healthchecks api. + + Args: + path (str): Path to request from + params (Optional[Dict[str, str]], optional): URL Parameters. Defaults to None. + + Returns: + str: url + """ + url = urljoin(self._api_url, path) + return self._add_url_params(url, params) if params is not None else url + + @property + def is_closed(self) -> bool: + """Is the client closed? + + Returns: + bool: is the client closed + """ + return self._client.is_closed + + @staticmethod + def _add_url_params(url: str, params: Dict[str, str], replace: bool = True): + """Add GET params to provided URL being aware of existing. + + :param url: string of target URL + :param params: dict containing requested params to be added + :param replace: bool True If true, replace params if they exist with new values, otherwise append + :return: string with updated URL + + >> url = 'http://stackoverflow.com/test?answers=true' + >> new_params = {'answers': False, 'data': ['some','values']} + >> add_url_params(url, new_params) + 'http://stackoverflow.com/test?data=some&data=values&answers=false' + """ + # Unquoting URL first so we don't loose existing args + url = unquote(url) + # Extracting url info + parsed_url = urlparse(url) + # Extracting URL arguments from parsed URL + get_args = parsed_url.query + # Converting URL arguments to dict + parsed_get_args = dict(parse_qsl(get_args)) + if replace: + # Merging URL arguments dict with new params + parsed_get_args.update(params) + extra_parameters = "" + else: + # get all the duplicated keys from params and urlencode them, we'll concat this to the params string later + duplicated_params = [x for x in params if x in parsed_get_args] + # get all the args that aren't duplicated and add them to parsed_get_args + parsed_get_args.update( + { + key: params[key] + for key in [x for x in params if x not in parsed_get_args] + } + ) + # if we have any duplicated parameters, urlencode them, we append them later + extra_parameters = ( + f"&{urlencode({key: params[key] for key in duplicated_params}, doseq=True)}" + if len(duplicated_params) > 0 + else "" + ) + + # Bool and Dict values should be converted to json-friendly values + # you may throw this part away if you don't like it :) + parsed_get_args.update( + { + k: dumps(v) + for k, v in parsed_get_args.items() + if isinstance(v, (bool, dict)) + } + ) + + # Converting URL argument to proper query string + encoded_get_args = f"{urlencode(parsed_get_args, doseq=True)}{extra_parameters}" + # Creating new parsed result object based on provided with new + # URL arguments. Same thing happens inside of urlparse. + new_url = ParseResult( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + parsed_url.params, + encoded_get_args, + parsed_url.fragment, + ).geturl() + + return new_url diff --git a/src/healthchecks_io/client/asyncclient.py b/src/healthchecks_io/client/asyncclient.py new file mode 100644 index 0000000..e845602 --- /dev/null +++ b/src/healthchecks_io/client/asyncclient.py @@ -0,0 +1,86 @@ +"""An async healthchecks.io client.""" +import asyncio +from typing import List +from typing import Optional + +from httpx import AsyncClient as HTTPXAsyncClient + +from ._abstract import AbstractClient +from .exceptions import HCAPIAuthError +from .exceptions import HCAPIError +from healthchecks_io import VERSION +from healthchecks_io.schemas import checks + + +class AsyncClient(AbstractClient): + """A Healthchecks.io client implemented using httpx's Async methods.""" + + def __init__( + self, + api_key: str, + api_url: Optional[str] = "https://healthchecks.io/api/", + api_version: Optional[int] = 1, + client: Optional[HTTPXAsyncClient] = None, + ) -> None: + """An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api. + + Args: + api_key (str): Healthchecks.io API key + api_url (Optional[str], optional): API URL. Defaults to "https://healthchecks.io/api/". + api_version (Optional[int], optional): Versiopn of the api to use. Defaults to 1. + client (Optional[HTTPXAsyncClient], optional): A httpx.Asyncclient. If not + passed in, one will be created for this object. Defaults to None. + """ + if client is None: + client = HTTPXAsyncClient() + super().__init__( + api_key=api_key, api_url=api_url, api_version=api_version, client=client + ) + self._client.headers["X-Api-Key"] = self._api_key + self._client.headers["user-agent"] = f"py-healthchecks.io/{VERSION}" + self._client.headers["Content-type"] = "application/json" + + def _finalizer_method(self): + """Calls _afinalizer_method from a sync context to work with weakref.finalizer.""" + asyncio.run(self._afinalizer_method()) + + async def _afinalizer_method(self): + """Finalizer coroutine that closes our client connections.""" + await self._client.aclose() + + async def get_checks(self, tags: Optional[List[str]] = None) -> List[checks.Check]: + """Get a list of checks from the healthchecks api. + + Args: + tags (Optional[List[str]], optional): Filters the checks and returns only + the checks that are tagged with the specified value. Defaults to None. + + Raises: + HCAPIAuthError: When the API returns a 401, indicates an api key issue + HCAPIError: When the API returns anything other than a 200 or 401 + + Returns: + List[checks.Check]: [description] + """ + request_url = self._get_api_request_url("checks/") + if tags is not None: + for tag in tags: + request_url = self._add_url_params( + request_url, {"tag": tag}, replace=False + ) + + response = await self._client.get(request_url) + + if response.status_code == 401: + raise HCAPIAuthError("Auth failure when getting checks") + + if response.status_code != 200: + raise HCAPIError( + f"Error when reaching out to HC API at {request_url}. " + f"Status Code {response.status_code}. Response {response.text}" + ) + + return [ + checks.Check.from_api_result(check_data) + for check_data in response.json()["checks"] + ] diff --git a/src/healthchecks_io/client/exceptions.py b/src/healthchecks_io/client/exceptions.py new file mode 100644 index 0000000..4356660 --- /dev/null +++ b/src/healthchecks_io/client/exceptions.py @@ -0,0 +1,13 @@ +"""healthchecks_io exceptions.""" + + +class HCAPIError(Exception): + """API Exception for when we have an error with the healthchecks api.""" + + ... + + +class HCAPIAuthError(HCAPIError): + """Thrown when we fail to auth to the Healthchecks api.""" + + ... diff --git a/src/healthchecks_io/schemas/__init__.py b/src/healthchecks_io/schemas/__init__.py index e69de29..991b605 100644 --- a/src/healthchecks_io/schemas/__init__.py +++ b/src/healthchecks_io/schemas/__init__.py @@ -0,0 +1 @@ +"""Schemas for healthchecks_io.""" diff --git a/tests/client/test_async.py b/tests/client/test_async.py new file mode 100644 index 0000000..6333df5 --- /dev/null +++ b/tests/client/test_async.py @@ -0,0 +1,76 @@ +from urllib.parse import urljoin + +import pytest +import respx +from httpx import AsyncClient as HTTPXAsyncClient +from httpx import Response + +from healthchecks_io.client import AsyncClient +from healthchecks_io.client.exceptions import HCAPIAuthError +from healthchecks_io.client.exceptions import HCAPIError + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_checks_200(fake_check_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/") + respx_mock.get(checks_url).mock( + return_value=Response(status_code=200, json={"checks": [fake_check_api_result]}) + ) + checks = await test_async_client.get_checks() + assert len(checks) == 1 + assert checks[0].name == fake_check_api_result["name"] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_checks_pass_in_client(fake_check_api_result, respx_mock): + httpx_client = HTTPXAsyncClient() + test_async_client = AsyncClient( + api_key="test", api_url="http://localhost/api/", client=httpx_client + ) + checks_url = urljoin(test_async_client._api_url, "checks/") + respx_mock.get(checks_url).mock( + return_value=Response(status_code=200, json={"checks": [fake_check_api_result]}) + ) + checks = await test_async_client.get_checks() + assert len(checks) == 1 + assert checks[0].name == fake_check_api_result["name"] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_checks_exceptions( + fake_check_api_result, respx_mock, test_async_client +): + checks_url = urljoin(test_async_client._api_url, "checks/") + # test exceptions + respx_mock.get(checks_url).mock(return_value=Response(status_code=401)) + with pytest.raises(HCAPIAuthError): + await test_async_client.get_checks() + + respx_mock.get(checks_url).mock(return_value=Response(status_code=500)) + with pytest.raises(HCAPIError): + await test_async_client.get_checks() + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_checks_tags(fake_check_api_result, respx_mock, test_async_client): + """Test get_checks with tags""" + checks_url = urljoin(test_async_client._api_url, "checks/") + respx_mock.get(f"{checks_url}?tag=test&tag=test2").mock( + return_value=Response(status_code=200, json={"checks": [fake_check_api_result]}) + ) + checks = await test_async_client.get_checks(tags=["test", "test2"]) + assert len(checks) == 1 + assert checks[0].name == fake_check_api_result["name"] + + +@pytest.mark.asyncio +def test_finalizer_closes(test_async_client): + """Tests our finalizer works to close the method""" + assert not test_async_client.is_closed + test_async_client._finalizer_method() + assert test_async_client.is_closed diff --git a/tests/conftest.py b/tests/conftest.py index b8a5b51..553bfe2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from typing import Union import pytest +from healthchecks_io.client import AsyncClient from healthchecks_io.schemas import checks @@ -78,3 +79,10 @@ def fake_ro_check(fake_check: checks.Check): fake_check.update_url = None fake_check.pause_url = None yield fake_check + + +@pytest.fixture +def test_async_client(): + """An AsyncClient for testing, set to a nonsense url so we aren't pinging healtchecks.""" + + yield AsyncClient(api_key="test", api_url="https://localhost/api") diff --git a/tests/schemas/test_badges.py b/tests/schemas/test_badges.py index f195158..cb6a4e3 100644 --- a/tests/schemas/test_badges.py +++ b/tests/schemas/test_badges.py @@ -1,14 +1,15 @@ from healthchecks_io.schemas.badges import Badges + def test_badge_from_api_result(): badges_dict = { - "svg": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.svg", - "svg3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.svg", - "json": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.json", - "json3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.json", - "shields": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.shields", - "shields3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.shields" + "svg": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.svg", + "svg3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.svg", + "json": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.json", + "json3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.json", + "shields": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.shields", + "shields3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.shields", } this_badge = Badges.from_api_result(badges_dict) - assert this_badge.svg == badges_dict['svg'] - assert this_badge.json_url == badges_dict['json'] + assert this_badge.svg == badges_dict["svg"] + assert this_badge.json_url == badges_dict["json"] diff --git a/tests/schemas/test_integrations.py b/tests/schemas/test_integrations.py index 11a829d..060cfc9 100644 --- a/tests/schemas/test_integrations.py +++ b/tests/schemas/test_integrations.py @@ -1,11 +1,12 @@ from healthchecks_io.schemas.integrations import Integration + def test_badge_from_api_result(): - int_dict = { - "id": "4ec5a071-2d08-4baa-898a-eb4eb3cd6941", - "name": "My Work Email", - "kind": "email" + int_dict = { + "id": "4ec5a071-2d08-4baa-898a-eb4eb3cd6941", + "name": "My Work Email", + "kind": "email", } this_integration = Integration.from_api_result(int_dict) - assert this_integration.id == int_dict['id'] - assert this_integration.name == int_dict['name'] + assert this_integration.id == int_dict["id"] + assert this_integration.name == int_dict["name"]