Skip to content

Commit

Permalink
feat: add project and tests structure with one example feature
Browse files Browse the repository at this point in the history
  • Loading branch information
danfimov committed Sep 17, 2023
1 parent 64333b6 commit 6a40372
Show file tree
Hide file tree
Showing 22 changed files with 617 additions and 0 deletions.
Empty file added tests/conftest.py
Empty file.
9 changes: 9 additions & 0 deletions tests/fixtures/issue.py
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
91 changes: 91 additions & 0 deletions tests/test_domain/test_client/test_initialization.py
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()
92 changes: 92 additions & 0 deletions tests/test_domain/test_client/test_request.py
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 tests/test_domain/test_repositories/test_issue_repository.py
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.
6 changes: 6 additions & 0 deletions ya_tacker_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from ya_tacker_client.service.api import YaTrackerClient


__all__ = [
"YaTrackerClient",
]
6 changes: 6 additions & 0 deletions ya_tacker_client/domain/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .base import BaseClient


__all__ = [
"BaseClient",
]
106 changes: 106 additions & 0 deletions ya_tacker_client/domain/client/base.py
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.
"""
28 changes: 28 additions & 0 deletions ya_tacker_client/domain/client/errors.py
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):
...
7 changes: 7 additions & 0 deletions ya_tacker_client/domain/entities/base.py
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):
...
Loading

0 comments on commit 6a40372

Please sign in to comment.