diff --git a/.github/workflows/pytest-dev.yml b/.github/workflows/pytest-dev.yml new file mode 100644 index 0000000..a4ffd3b --- /dev/null +++ b/.github/workflows/pytest-dev.yml @@ -0,0 +1,100 @@ +name: Pytest Tests For Development + +on: + workflow_dispatch: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + test: + strategy: + matrix: + include: + - os: windows-latest + python-version: 3.11 + - os: ubuntu-latest + python-version: 3.11 + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv (Linux/macOS) + if: runner.os != 'Windows' + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install uv (Windows) + if: runner.os == 'Windows' + run: | + python -m pip install uv + + - name: Install dependencies (Linux/macOS) + if: runner.os != 'Windows' + run: | + uv venv .venv + source .venv/bin/activate + uv sync --no-install-project --group test --group dev + + - name: Install dependencies (Windows) + if: runner.os == 'Windows' + run: | + uv venv .venv + .venv\Scripts\activate + uv sync --no-install-project --group test --group dev + + - name: Run pytest tests (Linux/macOS) + if: runner.os != 'Windows' + run: | + source .venv/bin/activate + uv run pytest tests/test_01_message_pytest.py -v --cov=hololinked --cov-report=term-missing + + - name: Run pytest tests (Windows) + if: runner.os == 'Windows' + run: | + .venv\Scripts\activate + uv run pytest tests/test_01_message_pytest.py -v --cov=hololinked --cov-report=term-missing + + - name: Upload coverage report as artifact + uses: actions/upload-artifact@v4 + if: runner.os != 'Windows' + with: + name: pytest-coverage-report-ubuntu-latest-py3.11 + path: coverage.xml + if-no-files-found: warn + + publish: + name: Publish coverage (disabled for pytest per issue #107) + needs: test + runs-on: ubuntu-latest + if: ${{ false }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download Ubuntu 3.11 coverage artifact + id: dl + uses: actions/download-artifact@v4 + with: + name: pytest-coverage-report-ubuntu-latest-py3.11 + path: . + continue-on-error: true + + - name: Upload coverage to Codecov (disabled) + if: false + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + + - name: Skip note (coverage upload disabled for pytest) + run: echo "Skipping Codecov upload in pytest workflow per issue #107." diff --git a/pyproject.toml b/pyproject.toml index 8c7a5f9..5eeb8a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,8 +81,30 @@ test = [ "numpy>=2.0.0", "faker==37.5.0", "bcrypt==4.3.0", - "fastjsonschema==2.20.0" + "fastjsonschema==2.20.0", + "pytest>=8.0.0", + "pytest-cov>=4.0.0", + "pytest-order>=1.0.0" ] linux = [ "uvloop==0.20.0" ] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra --strict-markers --strict-config" +testpaths = ["tests/pytest"] +python_files = ["test_*_pytest.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +markers = [ + "order: mark test to run in a specific order", + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests" +] +filterwarnings = [ + "error", + "ignore::UserWarning", + "ignore::DeprecationWarning", + "ignore::pytest.PytestCollectionWarning" +] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9da1aed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +""" +Pytest configuration and shared fixtures for hololinked tests. +""" +import asyncio +import pytest +import zmq.asyncio +from uuid import uuid4 +from faker import Faker + +from hololinked.config import global_config + + +@pytest.fixture(scope="session") +def event_loop(): + """Create an instance of the default event loop for the test session.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="class") +def zmq_context(): + """Setup ZMQ context for test classes.""" + global_config.ZMQ_CONTEXT = zmq.asyncio.Context() + yield global_config.ZMQ_CONTEXT + # Cleanup is handled by the context manager + + +@pytest.fixture(scope="class") +def test_ids(): + """Generate unique test IDs for each test class.""" + return { + "server_id": f"test-server-{uuid4().hex[:8]}", + "client_id": f"test-client-{uuid4().hex[:8]}", + "thing_id": f"test-thing-{uuid4().hex[:8]}" + } + + +@pytest.fixture(scope="session") +def fake(): + """Provide a Faker instance for generating test data.""" + return Faker() + + +@pytest.fixture(autouse=True) +def setup_test_environment(zmq_context): + """Automatically setup test environment for each test.""" + # This fixture runs automatically for every test + pass + + +def pytest_configure(config): + """Configure pytest with custom settings.""" + config.addinivalue_line( + "markers", "order: mark test to run in a specific order" + ) + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to add ordering markers.""" + # Add order markers based on test file names + for item in items: + if "test_01_" in item.nodeid: + item.add_marker(pytest.mark.order(1)) + elif "test_00_" in item.nodeid: + item.add_marker(pytest.mark.order(0)) diff --git a/tests/pytest/test_01_message_pytest.py b/tests/pytest/test_01_message_pytest.py new file mode 100644 index 0000000..4eeaede --- /dev/null +++ b/tests/pytest/test_01_message_pytest.py @@ -0,0 +1,212 @@ +""" +Pytest tests for message validation and messaging contract. +Converted from unittest to pytest format. +""" +import pytest +from uuid import UUID, uuid4 + +from hololinked.core.zmq.message import ( + EXIT, + OPERATION, + HANDSHAKE, + PreserializedData, + SerializableData, + RequestHeader, + EventHeader, + RequestMessage, +) # client to server +from hololinked.core.zmq.message import ( + TIMEOUT, + INVALID_MESSAGE, + ERROR, + REPLY, + ERROR, + ResponseMessage, + ResponseHeader, + EventMessage, +) # server to client +from hololinked.serializers.serializers import Serializers + + +class MessageValidatorMixin: + """A mixin class to validate request and response messages""" + + @pytest.fixture(autouse=True) + def setup_message_validator(self, test_ids): + """Setup message validator with test IDs.""" + self.server_id = test_ids["server_id"] + self.client_id = test_ids["client_id"] + self.thing_id = test_ids["thing_id"] + + def validate_request_message(self, request_message: RequestMessage) -> None: + """call this method to validate request message""" + + # req. 1. check message ID is a UUID + assert isinstance(request_message.id, UUID) or isinstance(UUID(request_message.id, version=4), UUID) + # req. 2. generated byte array must confine to predefined length (which is readonly & fixed) + assert len(request_message.byte_array) == request_message.length + # req. 3. receiver which must be the server ID + assert request_message.receiver_id == self.server_id + # req. 4. sender_id is the client ID + assert request_message.sender_id == self.client_id + # req. 5. all indices of byte array are bytes + for obj in request_message.byte_array: + assert isinstance(obj, bytes) + # req. 6. check that header is correct type (RequestHeader dataclass/struct) + assert isinstance(request_message.header, RequestHeader) + # req. 7 check that body is correct type (list of SerializableData and PreserializedData) + assert isinstance(request_message.body, list) + assert len(request_message.body) == 2 + assert isinstance(request_message.body[0], SerializableData) + assert isinstance(request_message.body[1], PreserializedData) + + def validate_response_message(self, response_message: ResponseMessage) -> None: + """call this method to validate response message""" + + # check message ID is a UUID + assert isinstance(response_message.id, UUID) or isinstance(UUID(response_message.id, version=4), UUID) + # check message length + assert len(response_message.byte_array) == response_message.length + # check receiver which must be the client + assert response_message.receiver_id == self.client_id + # sender_id is not set before sending message on the socket + assert response_message.sender_id == self.server_id + # check that all indices are bytes + for obj in response_message.byte_array: + assert isinstance(obj, bytes) + # check that header is correct type + assert isinstance(response_message.header, ResponseHeader) + # check that body is correct type + assert isinstance(response_message.body, list) + assert len(response_message.body) == 2 + assert isinstance(response_message.body[0], SerializableData) + assert isinstance(response_message.body[1], PreserializedData) + + def validate_event_message(self, event_message: EventMessage) -> None: + """call this method to validate event message""" + + # check message ID is a UUID + assert isinstance(event_message.id, UUID) or isinstance(UUID(event_message.id, version=4), UUID) + # check message length + assert len(event_message.byte_array) == event_message.length + # no receiver id for event message, only event id + assert isinstance(event_message.event_id, str) + # sender_id is not set before sending message on the socket + assert event_message.sender_id == self.server_id + # check that all indices are bytes + for obj in event_message.byte_array: + assert isinstance(obj, bytes) + # check that header is correct type + assert isinstance(event_message.header, EventHeader) + # check that body is correct type + assert isinstance(event_message.body, list) + assert len(event_message.body) == 2 + assert isinstance(event_message.body[0], SerializableData) + assert isinstance(event_message.body[1], PreserializedData) + + +@pytest.mark.order(1) +class TestMessagingContract(MessageValidatorMixin): + """Tests request and response messages""" + + def test_1_request_message(self): + """test the request message""" + + # request messages types are OPERATION, HANDSHAKE & EXIT + request_message = RequestMessage.craft_from_arguments( + receiver_id=self.server_id, + sender_id=self.client_id, + thing_id=self.thing_id, + objekt="some_prop", + operation="readproperty", + ) + self.validate_request_message(request_message) + # check message type for the above craft_from_arguments method + assert request_message.type == OPERATION + + request_message = RequestMessage.craft_with_message_type( + receiver_id=self.server_id, sender_id=self.client_id, message_type=HANDSHAKE + ) + self.validate_request_message(request_message) + # check message type for the above craft_with_message_type method + assert request_message.type == HANDSHAKE + + request_message = RequestMessage.craft_with_message_type( + receiver_id=self.server_id, sender_id=self.client_id, message_type=EXIT + ) + self.validate_request_message(request_message) + # check message type for the above craft_with_message_type method + assert request_message.type == EXIT + + def test_2_response_message(self): + """test the response message""" + + # response messages types are HANDSHAKE, TIMEOUT, INVALID_MESSAGE, ERROR and REPLY + response_message = ResponseMessage.craft_from_arguments( + receiver_id=self.client_id, + sender_id=self.server_id, + message_type=HANDSHAKE, + message_id=uuid4(), + ) + self.validate_response_message(response_message) + # check message type for the above craft_with_message_type method + assert response_message.type == HANDSHAKE + + response_message = ResponseMessage.craft_from_arguments( + receiver_id=self.client_id, + sender_id=self.server_id, + message_type=TIMEOUT, + message_id=uuid4(), + ) + self.validate_response_message(response_message) + # check message type for the above craft_with_message_type method + assert response_message.type == TIMEOUT + + response_message = ResponseMessage.craft_from_arguments( + receiver_id=self.client_id, + sender_id=self.server_id, + message_type=INVALID_MESSAGE, + message_id=uuid4(), + ) + self.validate_response_message(response_message) + # check message type for the above craft_with_message_type method + assert response_message.type == INVALID_MESSAGE + + response_message = ResponseMessage.craft_from_arguments( + receiver_id=self.client_id, + sender_id=self.server_id, + message_type=ERROR, + message_id=uuid4(), + payload=SerializableData(Exception("test")), + ) + self.validate_response_message(response_message) + assert response_message.type == ERROR + assert isinstance(Serializers.json.loads(response_message._bytes[2]), dict) + + request_message = RequestMessage.craft_from_arguments( + sender_id=self.client_id, + receiver_id=self.server_id, + thing_id=self.thing_id, + objekt="some_prop", + operation="readProperty", + ) + request_message._sender_id = self.client_id # will be done by craft_from_self + response_message = ResponseMessage.craft_reply_from_request( + request_message=request_message, + ) + self.validate_response_message(response_message) + assert response_message.type == REPLY + assert Serializers.json.loads(response_message._bytes[3]) is None # INDEX_BODY = 3 + assert request_message.id == response_message.id + + def test_3_event_message(self): + """test the event message""" + event_message = EventMessage.craft_from_arguments( + event_id="test-event", + sender_id=self.server_id, + payload=SerializableData("test"), + preserialized_payload=PreserializedData(b"test"), + ) + self.validate_event_message(event_message) + +