From bea835a387df9775b5274cdacba7f00a14e48c2f Mon Sep 17 00:00:00 2001 From: MountainGod2 Date: Sun, 7 Apr 2024 14:20:47 -0600 Subject: [PATCH] fix: refactor package and update README.md --- .github/workflows/ci-cd.yml | 6 + README.md | 4 +- example.py => examples/example.py | 5 +- pyproject.toml | 7 +- src/chaturbate_poller/__init__.py | 10 +- src/chaturbate_poller/__main__.py | 54 --- src/chaturbate_poller/chaturbate_poller.py | 63 ++- src/chaturbate_poller/logging_config.py | 60 ++- tests/test_chaturbate_poller.py | 484 ++++++++++++--------- tests/test_logging_config.py | 23 + tests/test_main.py | 76 ---- 11 files changed, 382 insertions(+), 410 deletions(-) rename example.py => examples/example.py (96%) delete mode 100644 src/chaturbate_poller/__main__.py create mode 100644 tests/test_logging_config.py delete mode 100644 tests/test_main.py diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 1a833449..bd0500b8 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -29,6 +29,12 @@ run: poetry install --no-interaction --no-root - name: Install library run: poetry install --no-interaction + - name: Format with ruff + run: poetry run ruff format ./ + - name: Lint with ruff + run: poetry run ruff check --fix ./ + - name: Type check with mypy + run: poetry run mypy ./ - name: Test with pytest env: CB_USERNAME: ${{ secrets.CB_USERNAME }} diff --git a/README.md b/README.md index 62c47298..e7f9980d 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,12 @@ Replace `your_chaturbate_username` and `your_api_token` with your actual Chaturb To run the Chaturbate Poller, use the following command from the root directory of the project: ```bash -python -m chaturbate_poller +python examples/example.py ``` The application will start, log into the console, and begin fetching events from the Chaturbate API using the credentials provided in the `.env` file. -See `example.py` for more detailed usage instructions. +See `examples/example.py` for more detailed usage instructions. ## Development diff --git a/example.py b/examples/example.py similarity index 96% rename from example.py rename to examples/example.py index fc3ffa82..1d11c768 100644 --- a/example.py +++ b/examples/example.py @@ -1,5 +1,4 @@ -# chaturbate_poller/example.py -"""Example for the Chaturbate Poller module.""" +"""Example for the Chaturbate Poller module.""" # noqa: INP001 import asyncio import contextlib @@ -20,7 +19,7 @@ token = os.getenv("CB_TOKEN", "") -async def handle_event(event: Event) -> None: # noqa: C901, PLR0915, PLR0912 +async def handle_event(event: Event) -> None: # noqa: PLR0915, PLR0912, C901 """Handle different types of events.""" method = event.method object_data = event.object diff --git a/pyproject.toml b/pyproject.toml index 4d653ffb..1be0ee36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,8 +81,8 @@ exclude = [ line-length = 88 indent-width = 4 -# Assume Python 3.8 -target-version = "py38" +# Assume Python 3.10 +target-version = "py310" [tool.ruff.lint] select = ["ALL"] @@ -91,7 +91,8 @@ ignore = [ "ISC001", # single-line-implicit-string-concatenation "ANN101", "ANN102", # missing-type-self (redundant) "D203", # one-blank-line-before-class (incompatable) - "D213" # multi-line-summary-second-line (incompatable) + "D213", # multi-line-summary-second-line (incompatable) + "S101" # disable assert warning for tests ] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] diff --git a/src/chaturbate_poller/__init__.py b/src/chaturbate_poller/__init__.py index 01cfba0e..a06d7123 100644 --- a/src/chaturbate_poller/__init__.py +++ b/src/chaturbate_poller/__init__.py @@ -1,12 +1,9 @@ """chaturbate_poller package.""" -import logging.config - # Read version from installed package from importlib.metadata import version from .chaturbate_poller import ChaturbateClient -from .logging_config import LOGGING_CONFIG __version__ = version("chaturbate_poller") __author__ = "MountainGod2" @@ -18,10 +15,5 @@ __description__ = "A Chaturbate event poller." -# Configure logging -logging.config.dictConfig(LOGGING_CONFIG) -logger = logging.getLogger(__name__) -"""logging.Logger: The logger for the package.""" - -__all__ = ["ChaturbateClient", "logger"] +__all__ = ["ChaturbateClient"] """List[str]: The package exports.""" diff --git a/src/chaturbate_poller/__main__.py b/src/chaturbate_poller/__main__.py deleted file mode 100644 index ebd390c5..00000000 --- a/src/chaturbate_poller/__main__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Main module for the Chaturbate Poller.""" - -import asyncio -import contextlib -import logging -import os - -import httpx -from dotenv import load_dotenv - -from . import ChaturbateClient - -logger = logging.getLogger(__package__) - - -load_dotenv() - -username = os.getenv("CB_USERNAME", "") -"""str: The Chaturbate username.""" -token = os.getenv("CB_TOKEN", "") -"""str: The Chaturbate token.""" - - -async def main() -> None: - """Fetch Chaturbate events. - - Raises: - KeyboardInterrupt: If the user interrupts the program. - httpx.HTTPError: If an error occurs fetching events. - - """ - async with ChaturbateClient(username, token) as client: - url = None - try: - logger.info("Starting cb_poller module. Press Ctrl+C to stop.") - while True: - response = await client.fetch_events(url=url) - for event in response.events: - logger.info("Event: %s", event) - - logger.debug("Next URL: %s", response.next_url) - url = response.next_url if response.next_url else None - - except (KeyboardInterrupt, asyncio.CancelledError): - logger.debug("Cancelled fetching Chaturbate events.") - except httpx.HTTPError: - logger.warning("Error fetching Chaturbate events.") - finally: - logger.info("Stopping cb_poller module.") - - -if __name__ == "__main__": - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(main()) diff --git a/src/chaturbate_poller/chaturbate_poller.py b/src/chaturbate_poller/chaturbate_poller.py index 5cdd3909..7af3e1ad 100644 --- a/src/chaturbate_poller/chaturbate_poller.py +++ b/src/chaturbate_poller/chaturbate_poller.py @@ -1,14 +1,11 @@ """Chaturbate poller module.""" -from __future__ import annotations - import logging -from types import TracebackType # noqa: TCH003 +from types import TracebackType import backoff import httpx from httpx import HTTPStatusError, RequestError -from typing_extensions import Self from .constants import BASE_URL, ERROR_RANGE_END, ERROR_RANGE_START from .models import EventsAPIResponse @@ -20,40 +17,41 @@ class ChaturbateClient: """Client for fetching Chaturbate events. Args: - username: Chaturbate username. - token: Chaturbate token. - timeout: Timeout in seconds for the API request. - - Attributes: - base_url: Base URL for the Chaturbate API. - timeout: Timeout in seconds for the API request. - username: Chaturbate username. - token: Chaturbate token. - client: HTTPX AsyncClient instance. + username (str): The Chaturbate username. + token (str): The Chaturbate token. + timeout (int, optional): The timeout for the request. Defaults to None. + + Raises: + ValueError: If username or token are not provided. """ def __init__(self, username: str, token: str, timeout: int | None = None) -> None: """Initialize client. + Args: + username (str): The Chaturbate username. + token (str): The Chaturbate token. + timeout (int, optional): The timeout for the request. Defaults to None. + Raises: ValueError: If username or token are not provided. """ if not username or not token: - error_msg = "Chaturbate username and token are required." - raise ValueError(error_msg) + msg = "Chaturbate username and token are required." + raise ValueError(msg) + self.base_url = BASE_URL self.timeout = timeout self.username = username self.token = token self.client = httpx.AsyncClient() - async def __aenter__(self) -> Self: + async def __aenter__(self) -> "ChaturbateClient": """Enter client. Returns: - ChaturbateClient: Client instance. + ChaturbateClient: The client. """ - self.client = httpx.AsyncClient() logger.debug("Client opened.") return self @@ -63,13 +61,7 @@ async def __aexit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - """Exit client. - - Args: - exc_type: Exception type. - exc_value: Exception value. - traceback: Traceback. - """ + """Exit client.""" await self.client.aclose() @backoff.on_exception( @@ -78,13 +70,12 @@ async def __aexit__( max_time=20, giveup=lambda e: not need_retry(e), on_backoff=lambda details: logger.info( - "Backoff triggered. Retry: %s, Waiting: %s sec. before retrying.", + "Backoff triggered. Retry: %s, Waiting: %s seconds before retrying.", details.get("tries", ""), int(details.get("wait", 0)), ), on_giveup=lambda details: logger.info( - "Retry stopped: %s after %s attempts.", - details.get("exception", ""), + "Retry stopped after %s attempts.", details.get("tries", ""), ), ) @@ -92,14 +83,10 @@ async def fetch_events(self, url: str | None = None) -> EventsAPIResponse: """Fetch events from the Chaturbate API. Args: - url: URL to fetch events from. + url (str, optional): The URL to fetch events from. Defaults to None. Returns: - EventsAPIResponse: Response from the API. - - Raises: - HTTPStatusError: If the response status code is in the error range. - RequestError: If an error occurs during the request. + EventsAPIResponse: The events API response. """ if url is None: url = self._construct_url() @@ -111,7 +98,7 @@ def _construct_url(self) -> str: """Construct URL with username, token, and timeout. Returns: - str: Constructed URL. + str: The constructed URL. """ base_url = self.base_url.format(username=self.username, token=self.token) if self.timeout: @@ -123,10 +110,10 @@ def need_retry(exception: Exception) -> bool: """Retries requests on 500 series errors. Args: - exception: Exception raised by the request. + exception (Exception): The exception raised. Returns: - bool: True if the request should be retried, False otherwise. + bool: True if the request should be retried. """ if isinstance(exception, HTTPStatusError): status_code = exception.response.status_code diff --git a/src/chaturbate_poller/logging_config.py b/src/chaturbate_poller/logging_config.py index 4f846fb2..d79582f5 100644 --- a/src/chaturbate_poller/logging_config.py +++ b/src/chaturbate_poller/logging_config.py @@ -1,11 +1,46 @@ -# chaturbate_poller/src/chaturbate_poller/logging_config.py -"""Logging configuration for the application.""" +"""Logging configuration for the Chaturbate poller.""" +import datetime +import logging + +# Define a custom level for CRITICAL events +CRITICAL_LEVEL = 50 +logging.addLevelName(CRITICAL_LEVEL, "CRITICAL") + + +# Custom formatter for including module and function names +class CustomFormatter(logging.Formatter): + """Custom formatter for including module and function names.""" + + def format( + self, + record: logging.LogRecord, + ) -> str: + """Format the log record.""" + record.module = record.module.split(".")[-1] # Extract only module name + record.funcName = record.funcName or "" # Avoid NoneType error + return super().format(record) + + +# Get current timestamp +current_timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime( + "%Y-%m-%d_%H-%M-%S" +) + +# Logging configuration dictionary LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { - "standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}, + "standard": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + "detailed": { + "()": CustomFormatter, + "format": "%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s - %(message)s", # noqa: E501 + "datefmt": "%Y-%m-%d %H:%M:%S", + }, }, "handlers": { "console": { @@ -14,38 +49,39 @@ "level": "INFO", }, "file": { - "class": "logging.FileHandler", - "filename": "app.log", - "formatter": "standard", + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": f"app_{current_timestamp}.log", + "formatter": "detailed", "level": "DEBUG", + "when": "midnight", # Rotate logs at midnight + "backupCount": 7, # Keep 7 backup log files }, }, "loggers": { - "": { # root logger + "": { "handlers": ["console", "file"], "level": "INFO", "propagate": True, }, "chaturbate_poller": { - "handlers": ["console", "file"], + "handlers": ["file"], # No need for console output "level": "INFO", "propagate": False, }, "httpx": { - "handlers": ["console", "file"], + "handlers": ["file"], # No need for console output "level": "ERROR", "propagate": False, }, "httpcore": { - "handlers": ["console", "file"], + "handlers": ["file"], # No need for console output "level": "ERROR", "propagate": False, }, "backoff": { - "handlers": ["console", "file"], + "handlers": ["file"], # No need for console output "level": "ERROR", "propagate": False, }, }, } -"""Logging configuration for the application.""" diff --git a/tests/test_chaturbate_poller.py b/tests/test_chaturbate_poller.py index 4782e51d..d4d2c64d 100644 --- a/tests/test_chaturbate_poller.py +++ b/tests/test_chaturbate_poller.py @@ -1,241 +1,299 @@ -"""Tests for the chaturbate_poller module.""" +"""Tests for the Chaturbate Poller module.""" -from json import JSONDecodeError +import logging.config import pytest -import pytest_mock from chaturbate_poller.chaturbate_poller import ChaturbateClient, need_retry +from chaturbate_poller.logging_config import LOGGING_CONFIG from chaturbate_poller.models import EventsAPIResponse from httpx import ( + AsyncClient, HTTPStatusError, Request, - RequestError, Response, TimeoutException, ) +from pytest_mock import MockerFixture + +from .test_logging_config import TEST_LOGGING_CONFIG USERNAME = "testuser" TOKEN = "testtoken" # noqa: S105 TEST_URL = "https://events.testbed.cb.dev/events/testuser/testtoken/" - - -@pytest.fixture() -def http_client_mock(mocker: pytest_mock.MockFixture) -> Response: - """Mock the HTTP client.""" - return mocker.patch("httpx.AsyncClient.get") - - -@pytest.mark.parametrize( - ("url", "expected_success"), - [ - ("https://valid.url.com", True), - (None, True), - ("https://error.url.com", False), +EVENT_DATA = { + "events": [ + { + "method": "userEnter", + "object": { + "user": { + "username": "fan_user", + "inFanclub": True, + "hasTokens": True, + "isMod": False, + "gender": "m", + "recentTips": "none", + } + }, + "id": "event_id_1", + } ], -) -@pytest.mark.asyncio() -async def test_fetch_events( - url: str, - expected_success: bool, # noqa: FBT001 - http_client_mock, # noqa: ANN001 -) -> None: - """Test fetching events from the Chaturbate API.""" - client = ChaturbateClient(USERNAME, TOKEN) - - mock_response = Response(200, json={"events": [], "nextUrl": ""}) - mock_response.request = Request("GET", "https://test.com") - - if expected_success: - http_client_mock.return_value = mock_response - if url is None: - url = TEST_URL - result = await client.fetch_events(url) - assert isinstance( # noqa: S101 - result, EventsAPIResponse - ), "Expected EventsAPIResponse object." - assert ( # noqa: S101 - http_client_mock.call_count == 1 - ), "Expected one call to httpx.AsyncClient.get." - assert http_client_mock.call_args[0][0] == url, "Expected URL to match." # noqa: S101 - else: - http_client_mock.side_effect = HTTPStatusError( - message="Error", - request=Request("GET", "https://error.url.com"), - response=mock_response, - ) - - with pytest.raises(HTTPStatusError): - await client.fetch_events(url) + "nextUrl": TEST_URL, +} -@pytest.mark.asyncio() -async def test_fetch_events_with_timeout(http_client_mock) -> None: # noqa: ANN001 - """Test fetching events with a timeout.""" - client = ChaturbateClient(USERNAME, TOKEN, timeout=1) +@pytest.fixture(autouse=True) +def _setup_logging() -> None: + logging.config.dictConfig(TEST_LOGGING_CONFIG) - mock_response = Response(200, json={"events": [], "nextUrl": ""}) - mock_response.request = Request("GET", TEST_URL) - http_client_mock.return_value = mock_response +@pytest.fixture() +def http_client_mock(mocker: MockerFixture) -> MockerFixture: + """Fixture for mocking the httpx.AsyncClient.get method. - result = await client.fetch_events() + Args: + mocker (MockerFixture): The mocker fixture. - assert isinstance(result, EventsAPIResponse), "Expected EventsAPIResponse object." # noqa: S101 - assert ( # noqa: S101 - http_client_mock.call_count == 1 - ), "Expected one call to httpx.AsyncClient.get." - assert ( # noqa: S101 - http_client_mock.call_args.kwargs["timeout"] == 1 - ), "Expected a timeout of 1 second." + Returns: + MockerFixture: The mocker fixture. + """ + return mocker.patch("httpx.AsyncClient.get") -@pytest.mark.parametrize( - ("exception", "expected_retry"), - [ - ( - HTTPStatusError( - message="Server Error", - request=Request("GET", "https://error.url.com"), - response=Response(500), +@pytest.fixture() +def chaturbate_client() -> ChaturbateClient: + """Fixture for creating a ChaturbateClient instance. + + Returns: + ChaturbateClient: The ChaturbateClient instance. + """ + return ChaturbateClient(USERNAME, TOKEN) + + +class TestChaturbateClientInitialization: + """Tests for the ChaturbateClient initialization.""" + + def test_initialization(self) -> None: + """Test ChaturbateClient initialization.""" + client = ChaturbateClient(USERNAME, TOKEN) + assert client.username == USERNAME + assert client.token == TOKEN + + def test_initialization_with_timeout(self) -> None: + """Test ChaturbateClient initialization with timeout.""" + timeout = 10 + client = ChaturbateClient(USERNAME, TOKEN, timeout=timeout) + assert client.timeout == timeout + + def test_initialization_failure(self) -> None: + """Test ChaturbateClient initialization failure.""" + with pytest.raises( + ValueError, match="Chaturbate username and token are required." + ): + ChaturbateClient("", TOKEN) + with pytest.raises( + ValueError, match="Chaturbate username and token are required." + ): + ChaturbateClient(USERNAME, "") + + +class TestErrorHandling: + """Tests for error handling.""" + + @pytest.mark.parametrize( + ("exception", "expected_retry"), + [ + ( + HTTPStatusError( + message="Server Error", + request=Request("GET", "https://error.url.com"), + response=Response(500), + ), + True, ), - True, - ), # Retry on 500 status code - ( - HTTPStatusError( - message="Client Error", - request=Request("GET", "https://error.url.com"), - response=Response(400), + ( + HTTPStatusError( + message="Client Error", + request=Request("GET", "https://error.url.com"), + response=Response(400), + ), + False, ), - False, - ), # No retry on 400 status code - ], -) -def test_need_retry( - exception: Exception, - expected_retry: bool, # noqa: FBT001 -) -> None: - """Test need_retry() function.""" - assert ( # noqa: S101 - need_retry(exception) == expected_retry - ), "need_retry() did not return the expected result." - - -@pytest.mark.asyncio() -async def test_fetch_events_network_failures(http_client_mock) -> None: # noqa: ANN001 - """Test handling network failures in fetch_events.""" - client = ChaturbateClient(USERNAME, TOKEN) - http_client_mock.side_effect = RequestError( - message="Network error", request=Request("GET", "https://test.com") - ) - - with pytest.raises(RequestError): - await client.fetch_events(TEST_URL) - - -@pytest.mark.asyncio() -async def test_fetch_events_malformed_json(http_client_mock) -> None: # noqa: ANN001 - """Test handling malformed JSON in fetch_events.""" - client = ChaturbateClient(USERNAME, TOKEN) - - mock_response = Response(200, content=b"{not: 'json'}") - mock_response.request = Request("GET", "https://test.com") - - http_client_mock.return_value = mock_response - - with pytest.raises(JSONDecodeError): - await client.fetch_events(TEST_URL) - - -@pytest.mark.asyncio() -async def test_complete_event_flow(http_client_mock) -> None: # noqa: ANN001 - """Test complete event flow.""" - client = ChaturbateClient(USERNAME, TOKEN) - event_data = { - "events": [ - { - "method": "userEnter", - "object": { - "user": { - "username": "fan_user", - "inFanclub": True, - "hasTokens": True, - "isMod": False, - "gender": "m", - "recentTips": "none", - } - }, - "id": "event_id_1", - } ], - "nextUrl": TEST_URL, - } - - mock_response = Response(200, json=event_data) - mock_response.request = Request("GET", "https://test.com") - http_client_mock.return_value = mock_response - - response = await client.fetch_events() - assert len(response.events) == 1, "Expected one event." # noqa: S101 - assert ( # noqa: S101 - response.events[0].object.user.username - if response.events[0].object and response.events[0].object.user - else None - ) == "fan_user", "Event user does not match." - - -@pytest.mark.asyncio() -async def test_timeout_handling(http_client_mock) -> None: # noqa: ANN001 - """Test handling of timeouts in fetch_events.""" - client = ChaturbateClient(USERNAME, TOKEN, timeout=1) - http_client_mock.side_effect = TimeoutException(message="Timeout", request=None) - - with pytest.raises(TimeoutException): - await client.fetch_events(TEST_URL) - - -def test_chaturbate_client_initialization() -> None: - """Test ChaturbateClient initialization.""" - # Test successful initialization - client = ChaturbateClient(USERNAME, TOKEN) - assert client.username == USERNAME, "Client username not set correctly." # noqa: S101 - assert client.token == TOKEN, "Client token not set correctly." # noqa: S101 - - # Test initialization failure with missing username or token - with pytest.raises(ValueError, match="Chaturbate username and token are required."): - ChaturbateClient("", TOKEN) - - with pytest.raises(ValueError, match="Chaturbate username and token are required."): - ChaturbateClient(USERNAME, "") - - assert not client.client.is_closed, "Client should not be closed after init." # noqa: S101 - - -@pytest.mark.parametrize( - ("status_code", "should_succeed"), - [ - (200, True), - (404, False), - (500, False), - ], -) -@pytest.mark.asyncio() -async def test_fetch_events_http_statuses( - status_code: int, - should_succeed: bool, # noqa: FBT001 - http_client_mock, # noqa: ANN001 -) -> None: - """Test handling of different HTTP statuses in fetch_events.""" - client = ChaturbateClient(USERNAME, TOKEN) - - mock_response = Response(status_code, json={"events": [], "nextUrl": ""}) - mock_response.request = Request("GET", "https://test.com") - http_client_mock.return_value = mock_response - - if should_succeed: - result = await client.fetch_events(TEST_URL) - assert isinstance( # noqa: S101 - result, EventsAPIResponse - ), "Expected EventsAPIResponse object for 200 response." - else: + ) + def test_need_retry( + self, + exception: Exception, + expected_retry: bool, # noqa: FBT001 + ) -> None: + """Test need_retry function.""" + assert need_retry(exception) == expected_retry + + +class TestLoggingConfigurations: + """Tests for logging configurations.""" + + def test_module_logging_configuration(self) -> None: + """Test module logging configuration.""" + assert isinstance(LOGGING_CONFIG, dict) + assert LOGGING_CONFIG.get("version") == 1 + assert LOGGING_CONFIG.get("disable_existing_loggers") is False + + def test_detailed_formatter(self) -> None: + """Test detailed formatter.""" + from chaturbate_poller.logging_config import CustomFormatter + + formatter = CustomFormatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + log_record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Test message", + args=None, + exc_info=None, + ) + formatted = formatter.format(log_record) + assert "Test message" in formatted + + +class TestClientLifecycle: + """Tests for the client lifecycle.""" + + @pytest.mark.asyncio() + async def test_client_as_context_manager(self) -> None: + """Test client as a context manager.""" + async with ChaturbateClient(USERNAME, TOKEN) as client: + assert isinstance( + client.client, AsyncClient + ), "Client should be an instance of AsyncClient during context management." + + @pytest.mark.asyncio() + async def test_client_closed_correctly( + self, chaturbate_client: ChaturbateClient + ) -> None: + """Test client is closed correctly.""" + async with chaturbate_client: + pass + assert ( + chaturbate_client.client.is_closed + ), "Client should be closed after exiting context manager." + + +class TestMiscellaneous: + """Miscellaneous tests.""" + + def test_chaturbate_client_initialization(self) -> None: + """Test ChaturbateClient initialization.""" + client = ChaturbateClient(USERNAME, TOKEN) + assert client.username == USERNAME, "Username should be correctly initialized." + assert client.token == TOKEN, "Token should be correctly initialized." + + @pytest.mark.asyncio() + async def test_timeout_handling( + self, + http_client_mock, # noqa: ANN001 + chaturbate_client: ChaturbateClient, + ) -> None: + """Test timeout handling.""" + http_client_mock.side_effect = TimeoutException( + message="Timeout", request=Request("GET", TEST_URL) + ) + with pytest.raises(TimeoutException): + await chaturbate_client.fetch_events(TEST_URL) + + +class TestURLConstruction: + """Tests for URL construction.""" + + @pytest.mark.asyncio() + async def test_url_construction(self, chaturbate_client: ChaturbateClient) -> None: + """Test URL construction.""" + url = chaturbate_client._construct_url() # noqa: SLF001 + assert url == TEST_URL, "URL should be correctly constructed." + + @pytest.mark.asyncio() + async def test_url_construction_with_timeout( + self, chaturbate_client: ChaturbateClient + ) -> None: + """Test URL construction with timeout.""" + chaturbate_client.timeout = 10 + url = chaturbate_client._construct_url() # noqa: SLF001 + assert ( + url == f"{TEST_URL}?timeout=10" + ), "URL should be correctly constructed with timeout." + + @pytest.mark.asyncio() + async def test_url_construction_with_timeout_zero( + self, chaturbate_client: ChaturbateClient + ) -> None: + """Test URL construction with timeout zero.""" + chaturbate_client.timeout = 0 + url = chaturbate_client._construct_url() # noqa: SLF001 + assert url == TEST_URL, "URL should be correctly constructed without timeout." + + @pytest.mark.asyncio() + async def test_url_construction_with_timeout_none( + self, chaturbate_client: ChaturbateClient + ) -> None: + """Test URL construction with timeout None.""" + chaturbate_client.timeout = None + url = chaturbate_client._construct_url() # noqa: SLF001 + assert url == TEST_URL, "URL should be correctly constructed without timeout." + + +class TestEventFetching: + """Tests for fetching events.""" + + # Test url construction with no url passed + @pytest.mark.asyncio() + async def test_fetch_events_no_url( + self, + http_client_mock, # noqa: ANN001 + chaturbate_client: ChaturbateClient, + ) -> None: + """Test fetching events with no URL.""" + request = Request("GET", TEST_URL) + http_client_mock.return_value = Response(200, json=EVENT_DATA, request=request) + response = await chaturbate_client.fetch_events() + assert isinstance(response, EventsAPIResponse) + http_client_mock.assert_called_once_with(TEST_URL, timeout=None) + + @pytest.mark.asyncio() + async def test_fetch_events_malformed_json( + self, chaturbate_client: ChaturbateClient + ) -> None: + """Test fetching events with malformed JSON.""" + request = Request("GET", TEST_URL) + self.return_value = Response(200, content=b"{not: 'json'}", request=request) with pytest.raises(HTTPStatusError): - await client.fetch_events(TEST_URL) + await chaturbate_client.fetch_events(TEST_URL) + + @pytest.mark.asyncio() + @pytest.mark.parametrize( + ("status_code", "should_succeed"), + [ + (200, True), + (400, False), + (500, False), + ], + ) + async def test_fetch_events_http_statuses( + self, + http_client_mock, # noqa: ANN001 + status_code: int, + should_succeed: bool, # noqa: FBT001 + chaturbate_client: ChaturbateClient, + ) -> None: + """Test fetching events with different HTTP statuses.""" + request = Request("GET", TEST_URL) + http_client_mock.return_value = Response( + status_code, json=EVENT_DATA, request=request + ) + if should_succeed: + response = await chaturbate_client.fetch_events(TEST_URL) + assert isinstance(response, EventsAPIResponse) + else: + with pytest.raises(HTTPStatusError): + await chaturbate_client.fetch_events(TEST_URL) diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py new file mode 100644 index 00000000..98d71f3c --- /dev/null +++ b/tests/test_logging_config.py @@ -0,0 +1,23 @@ +"""Test logging configuration for the Chaturbate poller package. + +This configuration is used for testing purposes only. +""" + +# Logging configuration dictionary for testing +TEST_LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "DEBUG", + }, + }, + "loggers": { + "": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index f90e5328..00000000 --- a/tests/test_main.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tests for the main module.""" - -import asyncio -import logging -import signal -import subprocess -import time -from unittest.mock import AsyncMock - -import httpx -import pytest -from chaturbate_poller.__main__ import main - - -@pytest.mark.asyncio() -async def test_successful_event_fetching(mocker) -> None: # noqa: ANN001 - """Test successful event fetching.""" - mocker.patch("os.getenv", return_value="test_value") - mock_client_class = mocker.patch("chaturbate_poller.__main__.ChaturbateClient") - mock_client_instance = mock_client_class.return_value.__aenter__.return_value - mock_client_instance.fetch_events.side_effect = [ - AsyncMock(events=["event1", "event2"]), - asyncio.CancelledError, - ] - logger_mock = mocker.patch("chaturbate_poller.__main__.logger.info") - - await main() - - assert logger_mock.call_count >= 2 # noqa: PLR2004, S101 - - -@pytest.mark.asyncio() -async def test_http_error_handling(mocker) -> None: # noqa: ANN001 - """Test handling of HTTP errors.""" - mocker.patch("os.getenv", return_value="test_value") - mocker.patch( - "chaturbate_poller.__main__.ChaturbateClient.fetch_events", - side_effect=httpx.HTTPError("Error"), - ) - logger_mock = mocker.patch("chaturbate_poller.__main__.logger.warning") - - await main() - - logger_mock.assert_called_once_with("Error fetching Chaturbate events.") - - -@pytest.mark.asyncio() -async def test_keyboard_interrupt_handling(mocker) -> None: # noqa: ANN001 - """Test handling of KeyboardInterrupt.""" - mocker.patch("os.getenv", return_value="test_value") - mocker.patch( - "chaturbate_poller.__main__.ChaturbateClient.fetch_events", - side_effect=asyncio.CancelledError, - ) - logger_mock = mocker.patch("chaturbate_poller.__main__.logger.debug") - - await main() - - logger_mock.assert_any_call("Cancelled fetching Chaturbate events.") - - -def test_main_subprocess() -> None: - """Test the main module in a subprocess with improved error handling.""" - cmd = [".venv/bin/python", "-m", "chaturbate_poller"] - process = subprocess.Popen( - cmd, # noqa: S603 - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - time.sleep(5) - process.send_signal(signal.SIGINT) - stdout, stderr = process.communicate() - if process.returncode != 0: - logging.error("STDOUT:\n%s", stdout.decode()) - logging.error("STDERR:\n%s", stderr.decode()) - assert process.returncode == 0, "Subprocess did not exit cleanly." # noqa: S101