diff --git a/pdm.lock b/pdm.lock index 332b172..637b037 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "testing"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:9ce409e6d4b57704c11bf0e10f32df5c03636bff59704ef07a8bd82265668c34" +content_hash = "sha256:601014f72651e21a1c2c5592a2c66fa00ce682f0d7fc1b9ddcfd698ad7eeba30" [[package]] name = "annotated-types" @@ -870,6 +870,20 @@ files = [ {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] +[[package]] +name = "pytest-asyncio" +version = "0.23.4" +requires_python = ">=3.8" +summary = "Pytest support for asyncio" +groups = ["testing"] +dependencies = [ + "pytest<8,>=7.0.0", +] +files = [ + {file = "pytest-asyncio-0.23.4.tar.gz", hash = "sha256:2143d9d9375bf372a73260e4114541485e84fca350b0b6b92674ca56ff5f7ea2"}, + {file = "pytest_asyncio-0.23.4-py3-none-any.whl", hash = "sha256:b0079dfac14b60cd1ce4691fbfb1748fe939db7d0234b5aba97197d10fbe0fef"}, +] + [[package]] name = "python-dotenv" version = "1.0.1" diff --git a/playground/information.py b/playground/information.py new file mode 100644 index 0000000..f482f4c --- /dev/null +++ b/playground/information.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午4:34 +# @Author : sudoskys +# @File : information.py +# @Software: PyCharm +import asyncio +import os + +from dotenv import load_dotenv +from loguru import logger +from pydantic import SecretStr + +from novelai_python import APIError +from novelai_python import Information, InformationResp, JwtCredential + +load_dotenv() + +enhance = "year 2023,dynamic angle, best quality, amazing quality, very aesthetic, absurdres" +token = None +jwt = os.getenv("NOVELAI_JWT") or token + + +async def main(): + globe_s = JwtCredential(jwt_token=SecretStr(jwt)) + try: + _res = await Information().request( + session=globe_s + ) + _res: InformationResp + print(f"Information: {_res}") + print(_res.model_dump()) + except APIError as e: + logger.exception(e) + print(e.__dict__) + return + + +loop = asyncio.get_event_loop() +loop.run_until_complete(main()) diff --git a/pyproject.toml b/pyproject.toml index ad62a5b..70e7909 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "novelai-python" -version = "0.2.1" +version = "0.2.2" description = "Novelai Python Binding With Pydantic" authors = [ { name = "sudoskys", email = "coldlando@hotmail.com" }, @@ -30,6 +30,7 @@ Issues = "https://github.com/LlmKira/novelai-python/issues" [project.optional-dependencies] testing = [ "pytest>=7.4.4", + "pytest-asyncio>=0.23.4", ] [build-system] requires = ["pdm-backend"] diff --git a/src/novelai_python/__init__.py b/src/novelai_python/__init__.py index d7b6015..c23b032 100644 --- a/src/novelai_python/__init__.py +++ b/src/novelai_python/__init__.py @@ -10,6 +10,7 @@ ) from .credential import JwtCredential, LoginCredential from .sdk import GenerateImageInfer, ImageGenerateResp +from .sdk import Information, InformationResp from .sdk import Login, LoginResp from .sdk import Subscription, SubscriptionResp @@ -23,6 +24,9 @@ "Login", "LoginResp", + "Information", + "InformationResp", + "JwtCredential", "LoginCredential", diff --git a/src/novelai_python/_response/__init__.py b/src/novelai_python/_response/__init__.py index 034d591..24b1b3d 100644 --- a/src/novelai_python/_response/__init__.py +++ b/src/novelai_python/_response/__init__.py @@ -4,11 +4,13 @@ # @File : __init__.py.py # @Software: PyCharm from .ai.generate_image import ImageGenerateResp +from .user.information import InformationResp from .user.login import LoginResp from .user.subscription import SubscriptionResp __all__ = [ "ImageGenerateResp", "SubscriptionResp", - "LoginResp" + "LoginResp", + "InformationResp" ] diff --git a/src/novelai_python/_response/user/information.py b/src/novelai_python/_response/user/information.py new file mode 100644 index 0000000..8ff6920 --- /dev/null +++ b/src/novelai_python/_response/user/information.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午3:10 +# @Author : sudoskys +# @File : information.py +# @Software: PyCharm + +from pydantic import BaseModel, Field + + +class InformationResp(BaseModel): + emailVerified: bool = Field(..., description="Email verification status") + emailVerificationLetterSent: bool = Field(..., description="Email verification letter sent status") + trialActivated: bool = Field(..., description="Trial activation status") + trialActionsLeft: int = Field(..., description="Number of trial actions left") + trialImagesLeft: int = Field(..., description="Number of trial images left") + accountCreatedAt: int = Field(..., description="Account creation time") diff --git a/src/novelai_python/credential/ApiToken.py b/src/novelai_python/credential/ApiToken.py new file mode 100644 index 0000000..9170f95 --- /dev/null +++ b/src/novelai_python/credential/ApiToken.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午3:05 +# @Author : sudoskys +# @File : ApiToken.py +# @Software: PyCharm +from curl_cffi.requests import AsyncSession +from loguru import logger +from pydantic import SecretStr, Field, field_validator + +from ._base import CredentialBase + + +class ApiCredential(CredentialBase): + """ + JwtCredential is the base class for all credential. + """ + api_token: SecretStr = Field(None, description="api token") + _session: AsyncSession = None + + async def get_session(self, timeout: int = 180): + if not self._session: + self._session = AsyncSession(timeout=timeout, headers={ + "Authorization": f"Bearer {self.api_token.get_secret_value()}", + "Content-Type": "application/json", + "Origin": "https://novelai.net", + "Referer": "https://novelai.net/", + }, impersonate="chrome110") + return self._session + + @field_validator('api_token') + def check_api_token(cls, v: SecretStr): + if not v.get_secret_value().startswith("pst"): + logger.warning("api token should start with pst-") + return v diff --git a/src/novelai_python/credential/JwtToken.py b/src/novelai_python/credential/JwtToken.py index 7ad7615..80523f6 100644 --- a/src/novelai_python/credential/JwtToken.py +++ b/src/novelai_python/credential/JwtToken.py @@ -4,7 +4,8 @@ # @File : JwtToken.py # @Software: PyCharm from curl_cffi.requests import AsyncSession -from pydantic import SecretStr, Field +from loguru import logger +from pydantic import SecretStr, Field, field_validator from ._base import CredentialBase @@ -25,3 +26,9 @@ async def get_session(self, timeout: int = 180): "Referer": "https://novelai.net/", }, impersonate="chrome110") return self._session + + @field_validator('jwt_token') + def check_jwt_token(cls, v: SecretStr): + if not v.get_secret_value().startswith("ey"): + logger.warning("jwt_token should start with ey") + return v diff --git a/src/novelai_python/credential/_base.py b/src/novelai_python/credential/_base.py index 6e8840f..3e4c938 100644 --- a/src/novelai_python/credential/_base.py +++ b/src/novelai_python/credential/_base.py @@ -9,6 +9,7 @@ class CredentialBase(BaseModel): _session: AsyncSession = None + """会话""" async def get_session(self, timeout: int = 180): raise NotImplementedError diff --git a/src/novelai_python/sdk/__init__.py b/src/novelai_python/sdk/__init__.py index 3868355..2f079aa 100644 --- a/src/novelai_python/sdk/__init__.py +++ b/src/novelai_python/sdk/__init__.py @@ -5,5 +5,6 @@ # @Software: PyCharm from .ai.generate_image import GenerateImageInfer, ImageGenerateResp # noqa 401 +from .user.information import Information, InformationResp # noqa 401 from .user.login import Login, LoginResp # noqa 401 from .user.subscription import Subscription, SubscriptionResp # noqa 401 diff --git a/src/novelai_python/sdk/ai/__init__.py b/src/novelai_python/sdk/ai/__init__.py index 9f6c192..b80a14a 100644 --- a/src/novelai_python/sdk/ai/__init__.py +++ b/src/novelai_python/sdk/ai/__init__.py @@ -3,5 +3,3 @@ # @Author : sudoskys # @File : __init__.py.py # @Software: PyCharm - -from .generate_image import * # noqa: F403 diff --git a/src/novelai_python/sdk/ai/generate_image.py b/src/novelai_python/sdk/ai/generate_image.py index e888635..a472fb4 100644 --- a/src/novelai_python/sdk/ai/generate_image.py +++ b/src/novelai_python/sdk/ai/generate_image.py @@ -18,8 +18,8 @@ from ..._exceptions import APIError, AuthError from ..._response import ImageGenerateResp -from ...utils import try_jsonfy, NovelAiMetadata from ...credential import CredentialBase +from ...utils import try_jsonfy, NovelAiMetadata class GenerateImageInfer(BaseModel): @@ -297,6 +297,9 @@ async def generate(self, session: Union[AsyncSession, "CredentialBase"], data=json.dumps(request_data).encode("utf-8") ) if response.headers.get('Content-Type') not in ['binary/octet-stream', 'application/x-zip-compressed']: + logger.error( + f"Error with content type: {response.headers.get('Content-Type')} and code: {response.status_code}" + ) try: _msg = response.json() except Exception: diff --git a/src/novelai_python/sdk/user/__init__.py b/src/novelai_python/sdk/user/__init__.py index d049979..a30dffe 100644 --- a/src/novelai_python/sdk/user/__init__.py +++ b/src/novelai_python/sdk/user/__init__.py @@ -3,5 +3,3 @@ # @Author : sudoskys # @File : __init__.py.py # @Software: PyCharm - -from .subscription import * # noqa: F403 diff --git a/src/novelai_python/sdk/user/information.py b/src/novelai_python/sdk/user/information.py new file mode 100644 index 0000000..6405017 --- /dev/null +++ b/src/novelai_python/sdk/user/information.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午3:09 +# @Author : sudoskys +# @File : information.py +# @Software: PyCharm +from typing import Optional, Union + +import httpx +from curl_cffi.requests import AsyncSession, RequestsError +from loguru import logger +from pydantic import BaseModel, PrivateAttr + +from ..._exceptions import APIError, AuthError +from ..._response.user.information import InformationResp +from ...credential import CredentialBase +from ...utils import try_jsonfy + + +class Information(BaseModel): + _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net") + + @property + def base_url(self): + return f"{self.endpoint.strip('/')}/user/information" + + @property + def endpoint(self): + return self._endpoint + + @endpoint.setter + def endpoint(self, value): + self._endpoint = value + + async def request(self, + session: Union[AsyncSession, CredentialBase], + ) -> InformationResp: + """ + Request to get user subscription information + :param session: + :return: + """ + if isinstance(session, CredentialBase): + session = await session.get_session() + request_data = {} + logger.debug("Information") + try: + assert hasattr(session, "get"), "session must have get method." + response = await session.get( + self.base_url, + ) + if "application/json" not in response.headers.get('Content-Type') or response.status_code != 200: + logger.error( + f"Error with content type: {response.headers.get('Content-Type')} and code: {response.status_code}" + ) + try: + _msg = response.json() + except Exception: + raise APIError( + message=f"Unexpected content type: {response.headers.get('Content-Type')}", + request=request_data, + status_code=response.status_code, + response=try_jsonfy(response.content) + ) + status_code = _msg.get("statusCode", response.status_code) + message = _msg.get("message", "Unknown error") + if status_code in [400, 401, 402]: + # 400 : validation error + # 401 : unauthorized + # 402 : payment required + # 409 : conflict + raise AuthError(message, request=request_data, status_code=status_code, response=_msg) + if status_code in [500]: + # An unknown error occured. + raise APIError(message, request=request_data, status_code=status_code, response=_msg) + raise APIError(message, request=request_data, status_code=status_code, response=_msg) + return InformationResp.model_validate(response.json()) + except RequestsError as exc: + logger.exception(exc) + raise RuntimeError(f"An AsyncSession error occurred: {exc}") + except httpx.HTTPError as exc: + raise RuntimeError(f"An HTTP error occurred: {exc}") + except APIError as e: + raise e + except Exception as e: + logger.opt(exception=e).exception("An Unexpected error occurred") + raise e diff --git a/src/novelai_python/sdk/user/login.py b/src/novelai_python/sdk/user/login.py index c1c128b..d5f6d9a 100644 --- a/src/novelai_python/sdk/user/login.py +++ b/src/novelai_python/sdk/user/login.py @@ -18,6 +18,7 @@ class Login(BaseModel): _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net") + _session: Optional[AsyncSession] = PrivateAttr(None) key: str = Field(..., description="User's key") @property @@ -34,11 +35,19 @@ def base_url(self): @property def session(self): - return AsyncSession(timeout=180, headers={ - "Content-Type": "application/json", - "Origin": "https://novelai.net", - "Referer": "https://novelai.net/", - }, impersonate="chrome110") + if self._session is None: + self._session = AsyncSession(timeout=180, headers={ + "Content-Type": "application/json", + "Origin": "https://novelai.net", + "Referer": "https://novelai.net/", + }, impersonate="chrome110") + return self._session + + @session.setter + def session(self, value): + if not isinstance(value, AsyncSession): + raise ValueError("session must be an instance of AsyncSession") + self._session = value @classmethod def build(cls, *, user_name: str, password: str): @@ -65,7 +74,9 @@ async def request(self, data=json.dumps(request_data).encode("utf-8") ) if "application/json" not in response.headers.get('Content-Type') or response.status_code != 201: - logger.error(f"Unexpected content type: {response.headers.get('Content-Type')}") + logger.error( + f"Error with content type: {response.headers.get('Content-Type')} and code: {response.status_code}" + ) try: _msg = response.json() except Exception: diff --git a/src/novelai_python/sdk/user/subscription.py b/src/novelai_python/sdk/user/subscription.py index 5545be4..227fc17 100644 --- a/src/novelai_python/sdk/user/subscription.py +++ b/src/novelai_python/sdk/user/subscription.py @@ -3,7 +3,7 @@ # @Author : sudoskys # @File : subscription.py.py # @Software: PyCharm -from typing import Optional, Union, Type +from typing import Optional, Union import httpx from curl_cffi.requests import AsyncSession, RequestsError @@ -49,7 +49,9 @@ async def request(self, self.base_url, ) if "application/json" not in response.headers.get('Content-Type') or response.status_code != 200: - logger.error(f"Unexpected content type: {response.headers.get('Content-Type')}") + logger.error( + f"Error with content type: {response.headers.get('Content-Type')} and code: {response.status_code}" + ) try: _msg = response.json() except Exception: diff --git a/src/novelai_python/utils/random_prompt/tag_character.py b/src/novelai_python/utils/random_prompt/tag_character.py index 56dd0d8..eee8025 100644 --- a/src/novelai_python/utils/random_prompt/tag_character.py +++ b/src/novelai_python/utils/random_prompt/tag_character.py @@ -71,7 +71,7 @@ ] rankIdentity = [ - ['vtuber', 20], ['accountant', 20], ['worker', 10], ['manager', 10], ['traffic_controller', 10], + ['virtual_youtuber', 60], ['accountant', 20], ['worker', 10], ['manager', 10], ['traffic_controller', 10], ['airline_cabin_crew', 10], ['art_therapist', 10], ['arts_administrator', 10], ['auditor', 10], ['automotive_engineer', 10], ['barrister', 10], @@ -99,7 +99,7 @@ ['interpreter', 10], ['postman', 10], ['processfor', 10], ['tailor', 10], ['technician', 10], ['waiter', 20], ['waitress', 50], ['journalist', 10], ['fisherman', 10], - ['vtuber', 90], ['landscape_architect', 10], ['learning_disability_nurse', 10], + ['vtuber', 70], ['landscape_architect', 10], ['learning_disability_nurse', 10], ['midwife', 10], ['photographer', 10], ['police_officer', 10], ['writer', 10], ['youth_worker', 10] ] diff --git a/tests/test_user_information.py b/tests/test_user_information.py new file mode 100644 index 0000000..56d8633 --- /dev/null +++ b/tests/test_user_information.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午3:17 +# @Author : sudoskys +# @File : test_information.py +# @Software: PyCharm +from unittest import mock + +import pytest +from curl_cffi.requests import AsyncSession + +from novelai_python import Information, InformationResp, AuthError, APIError + + +def test_endpoint_setter(): + info = Information() + assert info.endpoint == "https://api.novelai.net" + new_endpoint = "https://api.testai.net" + info.endpoint = new_endpoint + assert info.endpoint == new_endpoint + + +@pytest.mark.asyncio +async def test_request_method_successful(): + successful_response = mock.Mock() + successful_response.headers = {"Content-Type": "application/json"} + successful_response.status_code = 200 + successful_response.json.return_value = { + "emailVerified": True, + "emailVerificationLetterSent": True, + "trialActivated": True, + "trialActionsLeft": 0, + "trialImagesLeft": 0, + "accountCreatedAt": 0 + } + session = mock.MagicMock(spec=AsyncSession) + session.get = mock.AsyncMock(return_value=successful_response) + + info = Information() + resp = await info.request(session) + assert isinstance(resp, InformationResp) + + +@pytest.mark.asyncio +async def test_request_method_unauthorized_error(): + auth_response = mock.Mock() + auth_response.headers = {"Content-Type": "application/json"} + auth_response.status_code = 401 + auth_response.json.return_value = {"statusCode": 401, "message": "Access Token is incorrect."} + + session = mock.MagicMock(spec=AsyncSession) + session.get = mock.AsyncMock(return_value=auth_response) + + info = Information() + with pytest.raises(AuthError) as e: + await info.request(session) + assert e.type is AuthError + expected_message = 'Access Token is incorrect.' + assert expected_message == str(e.value) + + +@pytest.mark.asyncio +async def test_request_method_unknown_error(): + unknown_error_response = mock.Mock() + unknown_error_response.headers = {"Content-Type": "application/json"} + unknown_error_response.status_code = 500 + unknown_error_response.json.return_value = {"statusCode": 500, "message": "An unknown error occurred."} + + session = mock.MagicMock(spec=AsyncSession) + session.get = mock.AsyncMock(return_value=unknown_error_response) + + info = Information() + with pytest.raises(APIError) as e: + await info.request(session) + expected_message = 'An unknown error occurred.' + assert expected_message == str(e.value) diff --git a/tests/test_user_login.py b/tests/test_user_login.py new file mode 100644 index 0000000..ff3f7e0 --- /dev/null +++ b/tests/test_user_login.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午4:20 +# @Author : sudoskys +# @File : test_login.py +# @Software: PyCharm +from unittest import mock + +import pytest +from curl_cffi.requests import AsyncSession + +from novelai_python import APIError, Login, LoginResp + + +@pytest.mark.asyncio +async def test_successful_user_login(): + successful_login_response = mock.Mock() + successful_login_response.headers = {"Content-Type": "application/json"} + successful_login_response.status_code = 201 + successful_login_response.json.return_value = { + "accessToken": "string" + } + session = mock.MagicMock(spec=AsyncSession) + session.post = mock.AsyncMock(return_value=successful_login_response) + + login = Login(key="encoded_key") + login.session = session + resp = await login.request() + assert isinstance(resp, LoginResp) + assert resp.accessToken == "string" + + +@pytest.mark.asyncio +async def test_validation_error_during_login(): + validation_error_response = mock.Mock() + validation_error_response.headers = {"Content-Type": "application/json"} + validation_error_response.status_code = 400 + validation_error_response.json.return_value = { + "statusCode": 400, + "message": "A validation error occurred." + } + session = mock.MagicMock(spec=AsyncSession) + session.post = mock.AsyncMock(return_value=validation_error_response) + + login = Login(key="encoded_key") + login.session = session + with pytest.raises(APIError) as e: + await login.request() + assert e.type is APIError + expected_message = 'A validation error occurred.' + assert expected_message == str(e.value) + + +@pytest.mark.asyncio +async def test_incorrect_access_key_during_login(): + incorrect_key_response = mock.Mock() + incorrect_key_response.headers = {"Content-Type": "application/json"} + incorrect_key_response.status_code = 401 + incorrect_key_response.json.return_value = { + "statusCode": 401, + "message": "Access Key is incorrect." + } + session = mock.MagicMock(spec=AsyncSession) + session.post = mock.AsyncMock(return_value=incorrect_key_response) + + login = Login(key="encoded_key") + login.session = session + with pytest.raises(APIError) as e: + await login.request() + assert e.type is APIError + expected_message = 'Access Key is incorrect.' + assert expected_message == str(e.value) + + +@pytest.mark.asyncio +async def test_unknown_error_during_login(): + unknown_error_response = mock.Mock() + unknown_error_response.headers = {"Content-Type": "application/json"} + unknown_error_response.status_code = 500 + unknown_error_response.json.return_value = { + "statusCode": 500, + "message": "key must be longer than or equal to 64 characters" + } + session = mock.MagicMock(spec=AsyncSession) + session.post = mock.AsyncMock(return_value=unknown_error_response) + login = Login(key="encoded_key") + login.session = session + with pytest.raises(APIError) as e: + await login.request() + expected_message = 'key must be longer than or equal to 64 characters' + assert expected_message == str(e.value) diff --git a/tests/test_user_subscription.py b/tests/test_user_subscription.py new file mode 100644 index 0000000..3786b76 --- /dev/null +++ b/tests/test_user_subscription.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# @Time : 2024/2/8 下午4:31 +# @Author : sudoskys +# @File : test_subscription.py +# @Software: PyCharm +from unittest import mock + +import pytest +from curl_cffi.requests import AsyncSession + +from novelai_python import APIError, Subscription, SubscriptionResp, AuthError + + +@pytest.mark.asyncio +async def test_successful_subscription_request(): + successful_response = mock.Mock() + successful_response.headers = {"Content-Type": "application/json"} + successful_response.status_code = 200 + successful_response.json.return_value = { + "tier": 3, + "active": True, + "expiresAt": 1708646400, + "perks": { + "maxPriorityActions": 1000, + "startPriority": 10, + "moduleTrainingSteps": 10000, + "unlimitedMaxPriority": True, + "voiceGeneration": True, + "imageGeneration": True, + "unlimitedImageGeneration": True, + "unlimitedImageGenerationLimits": [ + { + "resolution": 4194304, + "maxPrompts": 0 + }, + { + "resolution": 1048576, + "maxPrompts": 1 + } + ], + "contextTokens": 8192 + }, + "paymentProcessorData": None, + "trainingStepsLeft": { + "fixedTrainingStepsLeft": 8825, + "purchasedTrainingSteps": 0 + }, + "accountType": 0 + } + session = mock.MagicMock(spec=AsyncSession) + session.get = mock.AsyncMock(return_value=successful_response) + subscription = Subscription() + resp = await subscription.request(session) + assert isinstance(resp, SubscriptionResp) + + +@pytest.mark.asyncio +async def test_incorrect_access_token_subscription_request(): + incorrect_token_response = mock.Mock() + incorrect_token_response.headers = {"Content-Type": "application/json"} + incorrect_token_response.status_code = 401 + incorrect_token_response.json.return_value = { + "statusCode": 401, + "message": "Access Token is incorrect." + } + session = mock.MagicMock(spec=AsyncSession) + session.get = mock.AsyncMock(return_value=incorrect_token_response) + + subscription = Subscription() + with pytest.raises(AuthError) as e: + await subscription.request(session) + assert e.type is AuthError + expected_message = 'Access Token is incorrect.' + assert expected_message == str(e.value) + + +@pytest.mark.asyncio +async def test_unknown_error_subscription_request(): + unknown_error_response = mock.Mock() + unknown_error_response.headers = {"Content-Type": "application/json"} + unknown_error_response.status_code = 500 + unknown_error_response.json.return_value = { + "statusCode": 500, + "message": "An unknown error occurred." + } + session = mock.MagicMock(spec=AsyncSession) + session.get = mock.AsyncMock(return_value=unknown_error_response) + + subscription = Subscription() + with pytest.raises(APIError) as e: + await subscription.request(session) + assert e.type is APIError + expected_message = 'An unknown error occurred.' + assert expected_message == str(e.value)