-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add project and tests structure with one example feature
- Loading branch information
Showing
22 changed files
with
617 additions
and
0 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from polyfactory.factories.pydantic_factory import ModelFactory | ||
from polyfactory.pytest_plugin import register_fixture | ||
|
||
from ya_tacker_client.domain.entities.issue import Issue | ||
|
||
|
||
@register_fixture | ||
class IssueFactory(ModelFactory[Issue]): | ||
__model__ = Issue |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
from http import HTTPStatus | ||
from random import randint | ||
from typing import Any | ||
|
||
import pytest | ||
|
||
from ya_tacker_client.domain.client import BaseClient | ||
from ya_tacker_client.domain.client.errors import ClientInitTokenError | ||
|
||
|
||
class ClientForTestInitialization(BaseClient): | ||
auth_token_header_value = "" | ||
organisation_token_name = "" | ||
|
||
async def _make_request( | ||
self, | ||
method: str, | ||
url: str, | ||
params: dict[str, Any] | None = None, | ||
data: bytes | None = None, | ||
) -> tuple[int, bytes]: | ||
return HTTPStatus.OK, b"Test response body" | ||
|
||
async def stop(self) -> None: | ||
pass | ||
|
||
def test_authorization_header(self) -> None: | ||
assert self._headers.get("Authorization") == self.auth_token_header_value | ||
|
||
def test_organisation_header(self) -> None: | ||
assert self._headers.get(self.organisation_token_name) | ||
|
||
|
||
def get_client_for_test_initialization( | ||
organisation_id: str | int, | ||
oauth_token: str | None = None, | ||
iam_token: str | None = None, | ||
auth_token_header_value: str = "", | ||
organisation_token_name: str = "", | ||
) -> ClientForTestInitialization: | ||
client = ClientForTestInitialization( | ||
organisation_id=organisation_id, | ||
oauth_token=oauth_token, | ||
iam_token=iam_token, | ||
) | ||
client.auth_token_header_value = auth_token_header_value | ||
client.organisation_token_name = organisation_token_name | ||
return client | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"organisation_id, header_name", | ||
( | ||
(randint(1, 1000), "X-Org-Id"), | ||
(str(randint(1, 1000)), "X-Org-Id"), | ||
("test_organisation_id", "X-Cloud-Org-Id"), | ||
), | ||
) | ||
def test_init__when_organisation_id_passed__then_use_specific_header_name(organisation_id, header_name) -> None: | ||
client = get_client_for_test_initialization( | ||
organisation_id=organisation_id, | ||
oauth_token="some_token", | ||
auth_token_header_value="OAuth some_token", | ||
organisation_token_name=header_name, | ||
) | ||
client.test_organisation_header() | ||
|
||
|
||
def test_init__when_auth_token_not_passed__then_raise_error() -> None: | ||
with pytest.raises(ClientInitTokenError): | ||
get_client_for_test_initialization( | ||
organisation_id=randint(1, 1000), | ||
) | ||
|
||
|
||
def test_init__when_oauth_token_passed__then_construct_specific_header_value() -> None: | ||
client = get_client_for_test_initialization( | ||
organisation_id=randint(1, 1000), | ||
oauth_token="some_test_token", | ||
auth_token_header_value="OAuth some_test_token", | ||
) | ||
client.test_authorization_header() | ||
|
||
|
||
def test_init__when_iam_token_passed__then_construct_specific_header_value() -> None: | ||
client = get_client_for_test_initialization( | ||
organisation_id=randint(1, 1000), | ||
iam_token="some_test_token", | ||
auth_token_header_value="Bearer some_test_token", | ||
) | ||
client.test_authorization_header() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
from http import HTTPStatus | ||
from logging import getLogger | ||
from random import randint | ||
from typing import TYPE_CHECKING, Any, Type | ||
|
||
import pytest | ||
|
||
from ya_tacker_client.domain.client import BaseClient | ||
from ya_tacker_client.domain.client.errors import ( | ||
ClientAuthError, | ||
ClientError, | ||
ClientObjectConflictError, | ||
ClientObjectNotFoundError, | ||
ClientSufficientRightsError, | ||
) | ||
|
||
|
||
if TYPE_CHECKING: | ||
pass | ||
|
||
|
||
logger = getLogger(__name__) | ||
|
||
|
||
class ClientForTestRequestStatus(BaseClient): | ||
make_request_status_code = 200 | ||
|
||
async def _make_request( | ||
self, | ||
method: str, | ||
url: str, | ||
params: dict[str, Any] | None = None, | ||
data: bytes | None = None, | ||
) -> tuple[int, bytes]: | ||
return self.make_request_status_code, b"Test response body" | ||
|
||
async def stop(self) -> None: | ||
pass | ||
|
||
|
||
def create_client_for_test_request_status(status_code: int) -> ClientForTestRequestStatus: | ||
client = ClientForTestRequestStatus( | ||
organisation_id=randint(1, 1000), | ||
oauth_token="test_token", | ||
) | ||
client.make_request_status_code = status_code | ||
return client | ||
|
||
|
||
class TestCheckStatus: | ||
""" | ||
Test with all statuses from documentation: https://cloud.yandex.com/en/docs/tracker/error-codes | ||
""" | ||
|
||
@pytest.mark.parametrize( | ||
"status_code", | ||
( | ||
HTTPStatus.OK, | ||
HTTPStatus.CREATED, | ||
HTTPStatus.NO_CONTENT, | ||
), | ||
) | ||
async def test_request__when_status_ok__then_not_raise_error(self, status_code: int) -> None: | ||
client = create_client_for_test_request_status(status_code) | ||
response_body = await client.request("GET", "/test_uri") | ||
assert response_body == b"Test response body" | ||
|
||
@pytest.mark.parametrize( | ||
"status_code, error_type", | ||
( | ||
(HTTPStatus.BAD_REQUEST, ClientError), | ||
(HTTPStatus.UNAUTHORIZED, ClientAuthError), | ||
(HTTPStatus.FORBIDDEN, ClientSufficientRightsError), | ||
(HTTPStatus.NOT_FOUND, ClientObjectNotFoundError), | ||
(HTTPStatus.CONFLICT, ClientObjectConflictError), | ||
(HTTPStatus.PRECONDITION_FAILED, ClientObjectConflictError), | ||
(HTTPStatus.UNPROCESSABLE_ENTITY, ClientError), | ||
(HTTPStatus.PRECONDITION_REQUIRED, ClientError), | ||
), | ||
) | ||
async def test_request__when_status_not_ok__then_raise_specific_error( | ||
self, | ||
status_code: int, | ||
error_type: Type[ClientError], | ||
) -> None: | ||
client = create_client_for_test_request_status(status_code) | ||
if error_type == ClientError: # always raise ClientException with context | ||
with pytest.raises(error_type, match="Test response body"): | ||
await client.request("GET", "/test_uri") | ||
else: | ||
with pytest.raises(error_type): | ||
await client.request("GET", "/test_uri") |
44 changes: 44 additions & 0 deletions
44
tests/test_domain/test_repositories/test_issue_repository.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
from random import randint | ||
from typing import Any | ||
|
||
import pytest | ||
|
||
from tests.fixtures.issue import IssueFactory | ||
|
||
from ya_tacker_client.domain.client import BaseClient | ||
from ya_tacker_client.domain.client.errors import ClientObjectNotFoundError | ||
from ya_tacker_client.domain.repositories.issue import IssueRepository | ||
|
||
|
||
class ClientForIssueRepository(BaseClient): | ||
make_request_status_code = 200 | ||
make_request_response_body = b"Test response body" | ||
|
||
async def _make_request( | ||
self, | ||
method: str, | ||
url: str, | ||
params: dict[str, Any] | None = None, | ||
data: bytes | None = None, | ||
) -> tuple[int, bytes]: | ||
return self.make_request_status_code, self.make_request_response_body | ||
|
||
async def stop(self) -> None: | ||
pass | ||
|
||
|
||
class TestIssueRepository: | ||
async def test_get_issue__when_issue_not_found__then_raise_error(self) -> None: | ||
client = ClientForIssueRepository(organisation_id=randint(1, 1000), oauth_token="test_token") | ||
client.make_request_status_code = 404 | ||
client.make_request_response_body = b"" | ||
|
||
with pytest.raises(ClientObjectNotFoundError): | ||
await IssueRepository(client=client).get_issue("NOT_EXISTS_ISSUE") | ||
|
||
async def test_get_issue__when_issue_found__then_return_it(self, issue_factory: IssueFactory) -> None: | ||
issue_instance = issue_factory.build() | ||
client = ClientForIssueRepository(organisation_id=randint(1, 1000), oauth_token="test_token") | ||
client.make_request_status_code = 200 | ||
client.make_request_response_body = bytes(issue_instance.model_dump_json(), encoding="utf-8") | ||
assert issue_instance == await IssueRepository(client=client).get_issue("EXISTING_ISSUE") |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from ya_tacker_client.service.api import YaTrackerClient | ||
|
||
|
||
__all__ = [ | ||
"YaTrackerClient", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from .base import BaseClient | ||
|
||
|
||
__all__ = [ | ||
"BaseClient", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
from abc import ABC, abstractmethod | ||
from http import HTTPStatus | ||
from logging import getLogger | ||
from typing import Any | ||
|
||
from ya_tacker_client.domain.client.errors import ( | ||
ClientAuthError, | ||
ClientError, | ||
ClientInitTokenError, | ||
ClientObjectConflictError, | ||
ClientObjectNotFoundError, | ||
ClientSufficientRightsError, | ||
) | ||
|
||
|
||
logger = getLogger(__name__) | ||
|
||
|
||
class BaseClient(ABC): | ||
""" | ||
Represents abstract base class for tracker client. | ||
""" | ||
|
||
def __init__( | ||
self, | ||
organisation_id: str | int, | ||
oauth_token: str | None = None, | ||
iam_token: str | None = None, | ||
api_host: str = "https://api.tracker.yandex.net", | ||
api_version: str = "v2", | ||
) -> None: | ||
self._headers: dict[str, str] = {} | ||
|
||
# Yandex 360 uses integer identifiers and Yandex Cloud prefer strings in identifiers | ||
if isinstance(organisation_id, int) or organisation_id.isdigit(): | ||
self._headers["X-Org-Id"] = str(organisation_id) | ||
else: | ||
self._headers["X-Cloud-Org-Id"] = organisation_id | ||
|
||
if oauth_token is not None: | ||
self._headers["Authorization"] = f"OAuth {oauth_token}" | ||
elif iam_token is not None: | ||
self._headers["Authorization"] = f"Bearer {iam_token}" | ||
else: | ||
raise ClientInitTokenError | ||
|
||
self._base_url = api_host | ||
self._api_version = api_version | ||
|
||
async def request( | ||
self, | ||
method: str, | ||
uri: str, | ||
params: dict[str, Any] | None = None, | ||
payload: dict[str, Any] | None = None, | ||
) -> bytes: | ||
uri = f"{self._base_url}/{self._api_version}{uri}" | ||
status, body = await self._make_request( | ||
method=method, | ||
url=uri, | ||
params=params, | ||
data=payload, | ||
) | ||
self._check_status(status, body) | ||
return body | ||
|
||
@abstractmethod | ||
async def _make_request( | ||
self, | ||
method: str, | ||
url: str, | ||
params: dict[str, Any] | None = None, | ||
data: bytes | None = None, | ||
) -> tuple[int, bytes]: | ||
""" | ||
Get raw response from via http-client. | ||
:returns: tuple of (status_code, response_body). | ||
""" | ||
|
||
@staticmethod | ||
def _check_status(status: int, body: bytes) -> None: | ||
if status <= HTTPStatus.IM_USED: | ||
return | ||
|
||
logger.exception("Response error. Status: %s. Body: %s", status, body) | ||
|
||
match status: | ||
case HTTPStatus.UNAUTHORIZED: | ||
raise ClientAuthError | ||
case HTTPStatus.FORBIDDEN: | ||
raise ClientSufficientRightsError | ||
case HTTPStatus.NOT_FOUND: | ||
raise ClientObjectNotFoundError | ||
case HTTPStatus.CONFLICT: | ||
raise ClientObjectConflictError | ||
case HTTPStatus.PRECONDITION_FAILED: | ||
raise ClientObjectConflictError | ||
case _: | ||
raise ClientError(body) | ||
|
||
@abstractmethod | ||
async def stop(self) -> None: | ||
""" | ||
Stop client gracefully - close all sessions. | ||
""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
class ClientError(RuntimeError): | ||
def __init__(self, message: str | bytes | None = None) -> None: | ||
if isinstance(message, bytes): | ||
self.message = message.decode("utf-8") | ||
else: | ||
self.message = message | ||
super().__init__(self.message) | ||
|
||
|
||
class ClientInitTokenError(ClientError): | ||
def __init__(self) -> None: | ||
super().__init__("Authorization token required. Please provide OAuth 2.0 token or IAM token.") | ||
|
||
|
||
class ClientAuthError(ClientError): | ||
... | ||
|
||
|
||
class ClientSufficientRightsError(ClientError): | ||
... | ||
|
||
|
||
class ClientObjectNotFoundError(ClientError): | ||
... | ||
|
||
|
||
class ClientObjectConflictError(ClientError): | ||
... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from abc import ABCMeta | ||
|
||
from pydantic import BaseModel | ||
|
||
|
||
class AbstractEntity(BaseModel, metaclass=ABCMeta): | ||
... |
Oops, something went wrong.