Skip to content

Commit 40b7ed9

Browse files
committed
✅ Add tests for edcpy
1 parent 98aedef commit 40b7ed9

File tree

7 files changed

+1276
-24
lines changed

7 files changed

+1276
-24
lines changed

edcpy/poetry.lock

Lines changed: 403 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

edcpy/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ pydantic = "^2.10.5"
2222

2323
[tool.poetry.group.dev.dependencies]
2424
pytest = "^7.3.1"
25+
pytest-asyncio = "^0.21.0"
26+
pytest-mock = "^3.10.0"
27+
httpx = "^0.27.0"
28+
aioresponses = "^0.7.4"
2529
black = "^23.3.0"
2630

2731
[build-system]

edcpy/tests/__init__.py

Whitespace-only changes.

edcpy/tests/conftest.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Pytest configuration and fixtures for edcpy tests.
3+
"""
4+
5+
import asyncio
6+
import os
7+
import tempfile
8+
from typing import Generator
9+
from unittest.mock import AsyncMock, MagicMock, patch
10+
11+
import pytest
12+
from fastapi.testclient import TestClient
13+
from faststream.rabbit import RabbitBroker, RabbitExchange
14+
15+
from edcpy.config import AppConfig
16+
from edcpy.messaging import MessagingApp
17+
18+
19+
@pytest.fixture(scope="session")
20+
def event_loop():
21+
"""Create an instance of the default event loop for the test session."""
22+
23+
loop = asyncio.get_event_loop_policy().new_event_loop()
24+
yield loop
25+
loop.close()
26+
27+
28+
@pytest.fixture
29+
def mock_cert_file() -> Generator[str, None, None]:
30+
"""Create a temporary certificate file for testing."""
31+
32+
cert_content = """-----BEGIN CERTIFICATE-----
33+
MIIBkTCB+wIJANVqXjqUvJxzMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv
34+
Y2FsaG9zdDAeFw0yMzEwMDEwMDAwMDBaFw0yNDEwMDEwMDAwMDBaMBQxEjAQBgNV
35+
BAMMCWxvY2FsaG9zdDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC8Q2J2J2J2J2J2
36+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
37+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
38+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
39+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
40+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
41+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
42+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
43+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
44+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
45+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
46+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
47+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
48+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
49+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
50+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
51+
J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2J2
52+
-----END CERTIFICATE-----"""
53+
54+
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
55+
f.write(cert_content)
56+
temp_path = f.name
57+
58+
yield temp_path
59+
60+
# Cleanup
61+
try:
62+
os.unlink(temp_path)
63+
except OSError:
64+
pass
65+
66+
67+
@pytest.fixture
68+
def mock_config(mock_cert_file) -> AppConfig:
69+
"""Create a mock configuration for testing."""
70+
71+
config = AppConfig()
72+
config.cert_path = mock_cert_file
73+
config.rabbit_url = "amqp://test:test@localhost:5672/test"
74+
config.http_api_port = 8000
75+
return config
76+
77+
78+
@pytest.fixture
79+
def mock_messaging_app() -> MessagingApp:
80+
"""Create a mock messaging app for testing."""
81+
82+
mock_broker = AsyncMock(spec=RabbitBroker)
83+
mock_exchange = MagicMock(spec=RabbitExchange)
84+
mock_app = MagicMock()
85+
86+
# Configure mock broker methods
87+
mock_broker.start = AsyncMock()
88+
mock_broker.close = AsyncMock()
89+
mock_broker.publish = AsyncMock()
90+
mock_broker.declare_exchange = AsyncMock()
91+
92+
messaging_app = MessagingApp(
93+
broker=mock_broker, app=mock_app, exchange=mock_exchange
94+
)
95+
96+
return messaging_app
97+
98+
99+
@pytest.fixture
100+
def mock_start_messaging_app(mock_messaging_app):
101+
"""Mock the start_messaging_app function."""
102+
103+
async def async_mock_start_messaging_app(*args, **kwargs):
104+
return mock_messaging_app
105+
106+
with patch(
107+
"edcpy.backend.start_messaging_app", side_effect=async_mock_start_messaging_app
108+
):
109+
yield mock_messaging_app
110+
111+
112+
@pytest.fixture
113+
def mock_get_config(mock_config):
114+
"""Mock the get_config function."""
115+
116+
with patch("edcpy.backend.get_config", return_value=mock_config):
117+
yield mock_config
118+
119+
120+
@pytest.fixture
121+
def test_client(mock_start_messaging_app, mock_get_config):
122+
"""Create a test client for the FastAPI app."""
123+
124+
from edcpy.backend import app
125+
126+
with TestClient(app) as client:
127+
yield client
128+
129+
130+
@pytest.fixture
131+
def sample_endpoint_data_reference():
132+
"""Sample EndpointDataReference data for testing."""
133+
134+
return {
135+
"id": "test-transfer-id",
136+
"endpoint": "https://provider.example.com/api/data",
137+
"authKey": "Authorization",
138+
"authCode": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkYWQiOiJ7XCJwcm9wZXJ0aWVzXCI6e1wibWV0aG9kXCI6XCJHRVRcIn19IiwiZXhwIjoxNzAwMDAwMDAwLCJpYXQiOjE2OTk5OTk5OTl9.mock_signature",
139+
"properties": {"key": "value"},
140+
"contractId": "contract-123",
141+
}
142+
143+
144+
@pytest.fixture
145+
def sample_push_data():
146+
"""Sample push data for testing."""
147+
148+
return {"message": "test data", "timestamp": "2023-01-01T00:00:00Z"}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Tests for backend API startup and shutdown functionality.
3+
"""
4+
5+
from unittest.mock import AsyncMock, patch
6+
7+
import pytest
8+
from fastapi.testclient import TestClient
9+
10+
from edcpy.backend import app, lifespan
11+
12+
13+
class TestBackendStartup:
14+
"""Test suite for backend startup functionality."""
15+
16+
def test_app_creation(self):
17+
"""Test that the FastAPI app can be created successfully."""
18+
19+
assert app is not None
20+
assert hasattr(app.router, "lifespan_context")
21+
22+
@pytest.mark.asyncio
23+
async def test_lifespan_startup_success(self, mock_messaging_app):
24+
"""Test successful startup of the messaging app during lifespan."""
25+
26+
mock_app = AsyncMock()
27+
28+
with patch(
29+
"edcpy.backend.start_messaging_app", return_value=mock_messaging_app
30+
):
31+
async with lifespan(mock_app):
32+
# Verify that the messaging app was set on the app state
33+
assert mock_app.state.messaging_app == mock_messaging_app
34+
35+
@pytest.mark.asyncio
36+
async def test_lifespan_shutdown_success(self, mock_messaging_app):
37+
"""Test successful shutdown of the messaging app during lifespan."""
38+
39+
mock_app = AsyncMock()
40+
41+
with patch(
42+
"edcpy.backend.start_messaging_app", return_value=mock_messaging_app
43+
):
44+
async with lifespan(mock_app):
45+
pass
46+
47+
# Verify that the broker was closed
48+
mock_messaging_app.broker.close.assert_called_once()
49+
50+
@pytest.mark.asyncio
51+
async def test_lifespan_shutdown_with_exception(self, mock_messaging_app):
52+
"""Test that shutdown handles exceptions gracefully."""
53+
54+
mock_app = AsyncMock()
55+
mock_messaging_app.broker.close.side_effect = Exception("Close error")
56+
57+
with patch(
58+
"edcpy.backend.start_messaging_app", return_value=mock_messaging_app
59+
):
60+
# Should not raise an exception even if broker close fails
61+
async with lifespan(mock_app):
62+
pass
63+
64+
# Verify that the broker close was attempted
65+
mock_messaging_app.broker.close.assert_called_once()
66+
67+
@pytest.mark.asyncio
68+
async def test_lifespan_startup_failure(self):
69+
"""Test that startup failure is handled properly."""
70+
71+
mock_app = AsyncMock()
72+
73+
with patch(
74+
"edcpy.backend.start_messaging_app", side_effect=Exception("Startup error")
75+
):
76+
# Should raise the exception from start_messaging_app
77+
with pytest.raises(Exception, match="Startup error"):
78+
async with lifespan(mock_app):
79+
pass
80+
81+
def test_test_client_creation(self, test_client):
82+
"""Test that the test client can be created successfully."""
83+
84+
assert test_client is not None
85+
assert isinstance(test_client, TestClient)
86+
87+
def test_app_state_access(self, test_client, mock_messaging_app):
88+
"""Test that the messaging app can be accessed from app state."""
89+
90+
# The test_client fixture should have set up the messaging app
91+
assert hasattr(test_client.app, "state")
92+
93+
# Since we're using mocked startup, we need to verify the mock is working
94+
with patch(
95+
"edcpy.backend.start_messaging_app", return_value=mock_messaging_app
96+
):
97+
# Create a new test client to trigger the lifespan
98+
with TestClient(app) as client:
99+
# The app should be accessible during the lifespan
100+
assert client.app is not None
101+
102+
103+
class TestBackendConfiguration:
104+
"""Test suite for backend configuration handling."""
105+
106+
def test_get_messaging_app_dependency(self, test_client, mock_messaging_app):
107+
"""Test that the messaging app dependency can be resolved."""
108+
109+
from edcpy.backend import get_messaging_app
110+
111+
# Create a mock request with app state
112+
mock_request = AsyncMock()
113+
mock_request.app.state.messaging_app = mock_messaging_app
114+
115+
result = get_messaging_app(mock_request)
116+
assert result == mock_messaging_app
117+
118+
def test_messaging_app_annotation(self):
119+
"""Test that the MessagingAppDep annotation is properly defined."""
120+
121+
from edcpy.backend import MessagingAppDep
122+
123+
assert MessagingAppDep is not None
124+
# The annotation should be a type annotation
125+
assert hasattr(MessagingAppDep, "__origin__")
126+
127+
128+
class TestBackendHealthCheck:
129+
"""Test suite for basic backend health checks."""
130+
131+
def test_app_routes_exist(self):
132+
"""Test that the expected routes are registered."""
133+
134+
routes = [
135+
getattr(route, "path", None)
136+
for route in app.routes
137+
if hasattr(route, "path")
138+
]
139+
140+
# Check that our main endpoints are registered
141+
assert "/pull" in routes
142+
assert "/push" in routes
143+
assert "/push/{routing_key_parts:path}" in routes
144+
145+
def test_app_lifespan_configured(self):
146+
"""Test that the app has lifespan configured."""
147+
148+
assert app.router.lifespan_context is not None

0 commit comments

Comments
 (0)