Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: base project structure #2

Merged
merged 6 commits into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: add project and tests structure with one example feature
  • Loading branch information
danfimov committed Sep 17, 2023
commit 6a403728266b2e90fd374fa51fb287919653ae31
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