First implementation API for PIP (Proyecto: Pharmacy Integration Platform)
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from src.infrastructure.database.models import Base
|
||||
from src.main import app
|
||||
|
||||
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://pip:pip-secret@localhost:5432/pip_test"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def test_engine():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pharmacy_data():
|
||||
return {
|
||||
"code": f"PHARM-{uuid4().hex[:8]}",
|
||||
"name": "Test Pharmacy",
|
||||
"address": "Calle Test 123",
|
||||
"city": "Madrid",
|
||||
"province": "Madrid",
|
||||
"postal_code": "28001",
|
||||
"country": "ES",
|
||||
"latitude": 40.4168,
|
||||
"longitude": -3.7038,
|
||||
"phone": "+34912345678",
|
||||
"email": "test@pharmacy.es",
|
||||
"erp_type": "farmatic",
|
||||
"sync_strategy": "incremental",
|
||||
"sync_interval_minutes": 30,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_data():
|
||||
return {
|
||||
"username": f"testuser_{uuid4().hex[:8]}",
|
||||
"password": "TestPass123!",
|
||||
"email": "test@pip.es",
|
||||
"full_name": "Test User",
|
||||
"role": "viewer",
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""E2E tests for API Key authentication flow.
|
||||
|
||||
Tests dual auth support (JWT Bearer + X-API-Key header),
|
||||
API Key CRUD, scope enforcement, and role-based access control.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000")
|
||||
ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")}
|
||||
|
||||
|
||||
async def get_admin_token(client: httpx.AsyncClient) -> str:
|
||||
r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS)
|
||||
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text[:200]}"
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def bearer_headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def test_api_key_crud():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
clients = await c.get("/api/v1/api-clients", headers=h)
|
||||
assert clients.status_code == 200
|
||||
client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
|
||||
|
||||
if not client_id:
|
||||
resp = await c.post("/api/v1/api-clients", headers=h, json={
|
||||
"name": "Test Client",
|
||||
"description": "E2E test client",
|
||||
"scopes": ["pharmacies:read", "medications:read", "inventory:read"],
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
client_id = resp.json()["id"]
|
||||
|
||||
key_resp = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "E2E CRUD Key"})
|
||||
assert key_resp.status_code == 201, f"Key create failed: {key_resp.status_code} {key_resp.text[:200]}"
|
||||
raw_key = key_resp.json()["key"]
|
||||
key_prefix = key_resp.json()["key_prefix"]
|
||||
assert len(raw_key) > 20
|
||||
assert len(key_prefix) == 8
|
||||
|
||||
keys = await c.get(f"/api/v1/api-clients/{client_id}/keys", headers=h)
|
||||
assert keys.status_code == 200
|
||||
assert len(keys.json()) >= 1
|
||||
|
||||
print(" PASS: test_api_key_crud")
|
||||
|
||||
|
||||
async def test_api_key_read_access():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
clients = await c.get("/api/v1/api-clients", headers=h)
|
||||
client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
|
||||
kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Read Access Key"})
|
||||
raw_key = kr.json()["key"]
|
||||
api_h = {"X-API-Key": raw_key}
|
||||
|
||||
r = await c.get("/api/v1/pharmacies", headers=api_h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["total"] >= 0
|
||||
|
||||
r2 = await c.get("/api/v1/medications", headers=api_h)
|
||||
assert r2.status_code == 200
|
||||
|
||||
r3 = await c.get("/api/v1/inventory", headers=api_h)
|
||||
assert r3.status_code == 200
|
||||
|
||||
print(" PASS: test_api_key_read_access")
|
||||
|
||||
|
||||
async def test_api_key_write_blocked():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
clients = await c.get("/api/v1/api-clients", headers=h)
|
||||
client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
|
||||
kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Write Block Key"})
|
||||
raw_key = kr.json()["key"]
|
||||
api_h = {"X-API-Key": raw_key}
|
||||
|
||||
r = await c.post("/api/v1/pharmacies", headers=api_h, json={"code": "BLOCKED", "name": "Should Fail"})
|
||||
assert r.status_code == 403, f"Expected 403, got {r.status_code}"
|
||||
|
||||
print(" PASS: test_api_key_write_blocked")
|
||||
|
||||
|
||||
async def test_invalid_api_key():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
r = await c.get("/api/v1/pharmacies", headers={"X-API-Key": "invalidkey12345"})
|
||||
assert r.status_code == 401
|
||||
|
||||
print(" PASS: test_invalid_api_key")
|
||||
|
||||
|
||||
async def test_no_auth_rejected():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
r = await c.get("/api/v1/pharmacies")
|
||||
assert r.status_code == 401
|
||||
|
||||
print(" PASS: test_no_auth_rejected")
|
||||
|
||||
|
||||
async def test_dual_auth_bearer_priority():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
clients = await c.get("/api/v1/api-clients", headers=h)
|
||||
client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
|
||||
kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Dual Auth Key"})
|
||||
raw_key = kr.json()["key"]
|
||||
|
||||
dual_h = {"Authorization": f"Bearer {token}", "X-API-Key": raw_key}
|
||||
r = await c.get("/api/v1/pharmacies", headers=dual_h)
|
||||
assert r.status_code == 200
|
||||
|
||||
print(" PASS: test_dual_auth_bearer_priority")
|
||||
|
||||
|
||||
async def run_all():
|
||||
print("=== API Key Auth E2E Tests ===")
|
||||
await test_api_key_crud()
|
||||
await test_api_key_read_access()
|
||||
await test_api_key_write_blocked()
|
||||
await test_invalid_api_key()
|
||||
await test_no_auth_rejected()
|
||||
await test_dual_auth_bearer_priority()
|
||||
print("=== All API Key tests passed ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_all())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""E2E tests for Rate Limiting middleware.
|
||||
|
||||
Tests that rate limiting returns 429 with proper headers
|
||||
when request count exceeds the configured limit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000")
|
||||
ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")}
|
||||
|
||||
|
||||
async def get_admin_token(client: httpx.AsyncClient) -> str:
|
||||
r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS)
|
||||
assert r.status_code == 200
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def bearer_headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def test_rate_limit_headers():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
r = await c.get("/api/v1/pharmacies", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert "x-ratelimit-limit" in r.headers, f"Missing X-RateLimit-Limit header. Got: {list(r.headers.keys())}"
|
||||
assert "x-ratelimit-remaining" in r.headers
|
||||
assert "x-ratelimit-reset" in r.headers
|
||||
|
||||
limit = int(r.headers["x-ratelimit-limit"])
|
||||
assert limit > 0
|
||||
|
||||
print(f" PASS: test_rate_limit_headers (limit={limit}/min)")
|
||||
|
||||
|
||||
async def test_rate_limit_429():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
r0 = await c.get("/api/v1/pharmacies", headers=h)
|
||||
limit = int(r0.headers["x-ratelimit-limit"])
|
||||
|
||||
successes = 0
|
||||
blocked = 0
|
||||
for i in range(limit + 10):
|
||||
r = await c.get("/api/v1/pharmacies", headers=h)
|
||||
if r.status_code == 200:
|
||||
successes += 1
|
||||
elif r.status_code == 429:
|
||||
blocked += 1
|
||||
assert "retry-after" in r.headers, "429 response missing Retry-After header"
|
||||
break
|
||||
|
||||
assert blocked > 0, f"Rate limit was not triggered after {limit + 10} requests"
|
||||
|
||||
print(f" PASS: test_rate_limit_429 (blocked after {successes} requests)")
|
||||
|
||||
|
||||
async def test_rate_limit_exempt_paths():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as c:
|
||||
r = await c.get("/docs")
|
||||
assert r.status_code == 200
|
||||
assert "x-ratelimit-limit" not in r.headers, "Docs should be exempt from rate limiting"
|
||||
|
||||
print(" PASS: test_rate_limit_exempt_paths")
|
||||
|
||||
|
||||
async def test_rate_limit_per_client():
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c:
|
||||
token = await get_admin_token(c)
|
||||
h = bearer_headers(token)
|
||||
|
||||
r0 = await c.get("/api/v1/pharmacies", headers=h)
|
||||
limit = int(r0.headers["x-ratelimit-limit"])
|
||||
|
||||
for _ in range(limit + 5):
|
||||
r = await c.get("/api/v1/pharmacies", headers=h)
|
||||
if r.status_code == 429:
|
||||
break
|
||||
|
||||
r2 = await c.get("/api/v1/system/health")
|
||||
assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths"
|
||||
|
||||
print(" PASS: test_rate_limit_per_client")
|
||||
|
||||
|
||||
async def run_all():
|
||||
print("=== Rate Limiting E2E Tests ===")
|
||||
await test_rate_limit_headers()
|
||||
await test_rate_limit_429()
|
||||
await test_rate_limit_exempt_paths()
|
||||
await test_rate_limit_per_client()
|
||||
print("=== All Rate Limiting tests passed ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_all())
|
||||
@@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.services.auth.security import create_access_token, hash_password, verify_password
|
||||
from src.domain.entities import UserRole
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
def test_hash_and_verify(self):
|
||||
password = "SecurePass123!"
|
||||
hashed = hash_password(password)
|
||||
assert hashed != password
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_verify_wrong_password(self):
|
||||
hashed = hash_password("CorrectPass123!")
|
||||
assert not verify_password("WrongPass123!", hashed)
|
||||
|
||||
|
||||
class TestJWT:
|
||||
def test_create_access_token(self):
|
||||
token = create_access_token(
|
||||
subject="test-user-id",
|
||||
role=UserRole.ADMIN,
|
||||
scopes=["*"],
|
||||
)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_decode_access_token(self):
|
||||
from src.services.auth.security import decode_token, validate_token_type, TokenData
|
||||
|
||||
token = create_access_token(
|
||||
subject="test-user-id",
|
||||
role=UserRole.VIEWER,
|
||||
scopes=["pharmacies:read"],
|
||||
)
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == "test-user-id"
|
||||
assert payload["role"] == "viewer"
|
||||
assert payload["type"] == "access"
|
||||
|
||||
validated = validate_token_type(payload, "access")
|
||||
assert validated is not None
|
||||
|
||||
def test_invalid_token_raises(self):
|
||||
from src.core.exceptions import UnauthorizedException
|
||||
from src.services.auth.security import decode_token
|
||||
|
||||
with pytest.raises(UnauthorizedException):
|
||||
decode_token("invalid.token.here")
|
||||
|
||||
|
||||
class TestRBAC:
|
||||
def test_superadmin_can_access_all(self):
|
||||
from src.services.auth.security import RBACGuard, TokenData
|
||||
|
||||
token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"})
|
||||
assert RBACGuard.has_role(token_data, UserRole.ADMIN)
|
||||
assert RBACGuard.has_role(token_data, UserRole.VIEWER)
|
||||
assert RBACGuard.has_role(token_data, UserRole.OPERATOR)
|
||||
|
||||
def test_viewer_cannot_access_admin(self):
|
||||
from src.services.auth.security import RBACGuard, TokenData
|
||||
|
||||
token_data = TokenData({"sub": "2", "role": "viewer", "scopes": [], "type": "access", "iss": "pip-platform", "jti": "test"})
|
||||
assert not RBACGuard.has_role(token_data, UserRole.ADMIN)
|
||||
|
||||
def test_wildcard_scope_grants_all(self):
|
||||
from src.services.auth.security import RBACGuard, TokenData
|
||||
|
||||
token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"})
|
||||
assert RBACGuard.has_scope(token_data, "pharmacies:read")
|
||||
assert RBACGuard.has_scope(token_data, "pharmacies:write")
|
||||
assert RBACGuard.has_scope(token_data, "anything:anything")
|
||||
|
||||
def test_specific_scope(self):
|
||||
from src.services.auth.security import RBACGuard, TokenData
|
||||
|
||||
token_data = TokenData({"sub": "2", "role": "viewer", "scopes": ["pharmacies:read"], "type": "access", "iss": "pip-platform", "jti": "test"})
|
||||
assert RBACGuard.has_scope(token_data, "pharmacies:read")
|
||||
assert not RBACGuard.has_scope(token_data, "pharmacies:write")
|
||||
|
||||
|
||||
class TestAPIKey:
|
||||
def test_generate_api_key(self):
|
||||
from src.services.auth.security import generate_api_key, verify_api_key
|
||||
|
||||
raw_key, prefix, key_hash = generate_api_key()
|
||||
assert len(raw_key) > 20
|
||||
assert len(prefix) == 8
|
||||
assert raw_key[:8] == prefix
|
||||
assert verify_api_key(raw_key, key_hash)
|
||||
assert not verify_api_key("wrong-key", key_hash)
|
||||
@@ -0,0 +1,102 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from src.connectors.registry import ConnectorRegistry, _BUILTIN_CONNECTORS
|
||||
from src.connectors.mock.mock_connector import MockConnector
|
||||
from src.connectors.base import BaseInventoryConnector
|
||||
from src.core.exceptions import ConnectorException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> ConnectorRegistry:
|
||||
ConnectorRegistry._instance = None
|
||||
reg = ConnectorRegistry.get_registry()
|
||||
return reg
|
||||
|
||||
|
||||
class TestConnectorRegistrySingleton:
|
||||
def test_singleton(self, registry: ConnectorRegistry):
|
||||
reg2 = ConnectorRegistry.get_registry()
|
||||
assert reg2 is registry
|
||||
|
||||
def test_new_instance_after_reset(self, registry: ConnectorRegistry):
|
||||
ConnectorRegistry._instance = None
|
||||
reg2 = ConnectorRegistry.get_registry()
|
||||
assert reg2 is not registry
|
||||
|
||||
|
||||
class TestConnectorRegistryBuiltinTypes:
|
||||
def test_builtin_connectors_include_mock(self):
|
||||
assert "mock" in _BUILTIN_CONNECTORS
|
||||
|
||||
def test_builtin_connectors_include_farmatic(self):
|
||||
assert "farmatic" in _BUILTIN_CONNECTORS
|
||||
|
||||
def test_builtin_connectors_include_custom_rest(self):
|
||||
assert "custom_rest" in _BUILTIN_CONNECTORS
|
||||
|
||||
|
||||
class TestConnectorRegistryGetClass:
|
||||
def test_get_mock_connector_class(self, registry: ConnectorRegistry):
|
||||
cls = registry.get_connector_class("mock")
|
||||
assert cls is MockConnector
|
||||
|
||||
def test_get_unknown_type_raises(self, registry: ConnectorRegistry):
|
||||
with pytest.raises(ConnectorException, match="No connector registered"):
|
||||
registry.get_connector_class("nonexistent_type")
|
||||
|
||||
|
||||
class TestConnectorRegistryCreateInstance:
|
||||
def test_create_mock_instance(self, registry: ConnectorRegistry):
|
||||
instance = registry.create_instance("mock")
|
||||
assert isinstance(instance, BaseInventoryConnector)
|
||||
assert instance.connector_type == "mock"
|
||||
|
||||
def test_create_instance_with_config(self, registry: ConnectorRegistry):
|
||||
instance = registry.create_instance("mock", {"simulate_latency": False, "error_rate": 0.0})
|
||||
assert instance._simulate_latency is False
|
||||
|
||||
def test_create_unknown_type_raises(self, registry: ConnectorRegistry):
|
||||
with pytest.raises(ConnectorException):
|
||||
registry.create_instance("nonexistent")
|
||||
|
||||
|
||||
class TestConnectorRegistryInstanceCache:
|
||||
def test_get_or_create_returns_cached(self, registry: ConnectorRegistry):
|
||||
cid = uuid4()
|
||||
inst1 = registry.get_or_create_instance(cid, "mock")
|
||||
inst2 = registry.get_or_create_instance(cid, "mock")
|
||||
assert inst1 is inst2
|
||||
|
||||
def test_remove_instance(self, registry: ConnectorRegistry):
|
||||
cid = uuid4()
|
||||
registry.get_or_create_instance(cid, "mock")
|
||||
registry.remove_instance(cid)
|
||||
assert cid not in registry._instances
|
||||
|
||||
def test_remove_nonexistent_instance_no_error(self, registry: ConnectorRegistry):
|
||||
registry.remove_instance(uuid4())
|
||||
|
||||
|
||||
class TestConnectorRegistryListTypes:
|
||||
def test_list_registered_types(self, registry: ConnectorRegistry):
|
||||
types = registry.list_registered_types()
|
||||
assert "mock" in types
|
||||
assert "farmatic" in types
|
||||
assert "custom_rest" in types
|
||||
|
||||
def test_register_custom_type(self, registry: ConnectorRegistry):
|
||||
mock = MockConnector()
|
||||
mock.connector_type = "test_custom"
|
||||
registry.register("test_custom", mock)
|
||||
types = registry.list_registered_types()
|
||||
assert "test_custom" in types
|
||||
|
||||
def test_unregister_type(self, registry: ConnectorRegistry):
|
||||
mock = MockConnector()
|
||||
mock.connector_type = "temp_type"
|
||||
registry.register("temp_type", mock)
|
||||
registry.unregister("temp_type")
|
||||
types = registry.list_registered_types()
|
||||
assert "temp_type" not in types
|
||||
@@ -0,0 +1,258 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.connectors.sdk.circuit_breaker import CircuitBreaker, CircuitState
|
||||
from src.connectors.sdk.retry import RetryPolicy, with_retry
|
||||
from src.connectors.sdk.context import ConnectorContext
|
||||
from src.connectors.sdk.auth import BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler
|
||||
from src.core.exceptions import ServiceUnavailableException
|
||||
|
||||
|
||||
class TestCircuitBreaker:
|
||||
def test_initial_state_is_closed(self):
|
||||
cb = CircuitBreaker(name="test")
|
||||
assert cb.state == CircuitState.CLOSED
|
||||
|
||||
def test_opens_after_threshold_failures(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=3)
|
||||
for _ in range(3):
|
||||
cb._on_failure(Exception("fail"))
|
||||
assert cb._state == CircuitState.OPEN
|
||||
|
||||
def test_closed_does_not_raise(self):
|
||||
cb = CircuitBreaker(name="test")
|
||||
|
||||
async def _run():
|
||||
coro = cb.call(AsyncMock(return_value="ok"))
|
||||
return await coro
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(_run())
|
||||
assert result == "ok"
|
||||
|
||||
def test_open_raises_service_unavailable(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=1)
|
||||
cb._on_failure(Exception("fail"))
|
||||
assert cb.state == CircuitState.OPEN
|
||||
|
||||
async def _run():
|
||||
await cb.call(AsyncMock(return_value="ok"))
|
||||
|
||||
import asyncio
|
||||
with pytest.raises(ServiceUnavailableException):
|
||||
asyncio.get_event_loop().run_until_complete(_run())
|
||||
|
||||
def test_half_open_after_recovery_timeout(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
|
||||
cb._on_failure(Exception("fail"))
|
||||
assert cb._state == CircuitState.OPEN
|
||||
time.sleep(0.02)
|
||||
assert cb.state == CircuitState.HALF_OPEN
|
||||
|
||||
def test_reset(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=1)
|
||||
cb._on_failure(Exception("fail"))
|
||||
cb.reset()
|
||||
assert cb.state == CircuitState.CLOSED
|
||||
assert cb._failure_count == 0
|
||||
|
||||
def test_success_resets_failure_count(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=5)
|
||||
cb._on_failure(Exception("fail"))
|
||||
cb._on_failure(Exception("fail"))
|
||||
assert cb._failure_count == 2
|
||||
cb._on_success()
|
||||
assert cb._failure_count == 0
|
||||
|
||||
def test_half_open_success_closes_circuit(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
|
||||
cb._on_failure(Exception("fail"))
|
||||
time.sleep(0.02)
|
||||
assert cb.state == CircuitState.HALF_OPEN
|
||||
cb._on_success()
|
||||
assert cb._state == CircuitState.CLOSED
|
||||
|
||||
def test_half_open_failure_reopens(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
|
||||
cb._on_failure(Exception("fail"))
|
||||
time.sleep(0.02)
|
||||
assert cb.state == CircuitState.HALF_OPEN
|
||||
cb._on_failure(Exception("fail again"))
|
||||
assert cb._state == CircuitState.OPEN
|
||||
|
||||
def test_get_status(self):
|
||||
cb = CircuitBreaker(name="test")
|
||||
status = cb.get_status()
|
||||
assert status["name"] == "test"
|
||||
assert status["state"] == "closed"
|
||||
|
||||
|
||||
class TestRetryPolicy:
|
||||
def test_default_values(self):
|
||||
policy = RetryPolicy()
|
||||
assert policy.max_attempts == 3
|
||||
assert policy.base_delay == 1.0
|
||||
assert policy.max_delay == 30.0
|
||||
|
||||
def test_custom_values(self):
|
||||
policy = RetryPolicy(max_attempts=5, base_delay=0.5, max_delay=10.0)
|
||||
assert policy.max_attempts == 5
|
||||
assert policy.base_delay == 0.5
|
||||
assert policy.max_delay == 10.0
|
||||
|
||||
|
||||
class TestWithRetry:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_first_try(self):
|
||||
call_count = 0
|
||||
|
||||
async def _success():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return "ok"
|
||||
|
||||
policy = RetryPolicy(max_attempts=3)
|
||||
result = await with_retry(_success, policy=policy, connector_type="test", operation="test_op")
|
||||
assert result == "ok"
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_failure_then_success(self):
|
||||
call_count = 0
|
||||
|
||||
async def _flaky():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise Exception("temp fail")
|
||||
return "ok"
|
||||
|
||||
policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
|
||||
result = await with_retry(_flaky, policy=policy, connector_type="test", operation="flaky_op")
|
||||
assert result == "ok"
|
||||
assert call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exhaust_retries(self):
|
||||
call_count = 0
|
||||
|
||||
async def _always_fail():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise Exception("always fail")
|
||||
|
||||
policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1)
|
||||
with pytest.raises(Exception, match="always fail"):
|
||||
await with_retry(_always_fail, policy=policy, connector_type="test", operation="fail_op")
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
class TestConnectorContext:
|
||||
def test_cache_key(self):
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="farmatic",
|
||||
)
|
||||
key = ctx.cache_key("stock")
|
||||
assert "farmatic" in key
|
||||
assert "stock" in key
|
||||
|
||||
def test_get_config(self):
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="mock",
|
||||
config={"base_url": "https://example.com"},
|
||||
)
|
||||
assert ctx.get_config("base_url") == "https://example.com"
|
||||
assert ctx.get_config("missing", "default") == "default"
|
||||
|
||||
def test_get_secret(self):
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="mock",
|
||||
secrets={"api_key": "secret123"},
|
||||
)
|
||||
assert ctx.get_secret("api_key") == "secret123"
|
||||
assert ctx.get_secret("missing") is None
|
||||
|
||||
def test_require_secret_raises(self):
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="mock",
|
||||
)
|
||||
with pytest.raises(ValueError, match="Required secret"):
|
||||
ctx.require_secret("missing")
|
||||
|
||||
def test_require_config_raises(self):
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="mock",
|
||||
)
|
||||
with pytest.raises(ValueError, match="Required config"):
|
||||
ctx.require_config("missing")
|
||||
|
||||
|
||||
class TestBasicAuthHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_basic_auth(self):
|
||||
import httpx
|
||||
|
||||
handler = BasicAuthHandler()
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="test",
|
||||
config={"username": "user1", "password": "pass1"},
|
||||
)
|
||||
request = httpx.Request("GET", "https://example.com")
|
||||
result = await handler.apply(request, ctx)
|
||||
assert "Basic" in result.headers["Authorization"]
|
||||
|
||||
|
||||
class TestBearerAuthHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_bearer_auth(self):
|
||||
import httpx
|
||||
|
||||
handler = BearerAuthHandler()
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="test",
|
||||
config={"access_token": "token123"},
|
||||
)
|
||||
request = httpx.Request("GET", "https://example.com")
|
||||
result = await handler.apply(request, ctx)
|
||||
assert result.headers["Authorization"] == "Bearer token123"
|
||||
|
||||
|
||||
class TestAPIKeyAuthHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_api_key_auth(self):
|
||||
import httpx
|
||||
|
||||
handler = APIKeyAuthHandler(header_name="X-Custom-Key")
|
||||
ctx = ConnectorContext(
|
||||
connector_id=MagicMock(),
|
||||
pharmacy_id=MagicMock(),
|
||||
erp_system_id=MagicMock(),
|
||||
connector_type="test",
|
||||
secrets={"api_key": "key123"},
|
||||
)
|
||||
request = httpx.Request("GET", "https://example.com")
|
||||
result = await handler.apply(request, ctx)
|
||||
assert result.headers["X-Custom-Key"] == "key123"
|
||||
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
|
||||
from src.domain.entities import (
|
||||
PharmacyEntity,
|
||||
PharmacyStatus,
|
||||
MedicationEntity,
|
||||
ERPType,
|
||||
SyncStrategy,
|
||||
UserRole,
|
||||
ConnectorEntity,
|
||||
ConnectorStatus,
|
||||
ReservationEntity,
|
||||
ReservationStatus,
|
||||
AuditLogEntity,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class TestPharmacyEntity:
|
||||
def test_create_pharmacy(self):
|
||||
entity = PharmacyEntity(
|
||||
code="PH-001",
|
||||
name="Farmacia Central",
|
||||
address="Calle Mayor 1",
|
||||
city="Madrid",
|
||||
status=PharmacyStatus.ACTIVE,
|
||||
erp_type=ERPType.FARMATIC,
|
||||
sync_strategy=SyncStrategy.INCREMENTAL,
|
||||
)
|
||||
assert entity.code == "PH-001"
|
||||
assert entity.name == "Farmacia Central"
|
||||
assert entity.status == PharmacyStatus.ACTIVE
|
||||
assert entity.erp_type == ERPType.FARMATIC
|
||||
assert entity.sync_strategy == SyncStrategy.INCREMENTAL
|
||||
|
||||
def test_pharmacy_default_values(self):
|
||||
entity = PharmacyEntity(code="PH-002", name="Test")
|
||||
assert entity.country == "ES"
|
||||
assert entity.status == PharmacyStatus.PENDING
|
||||
assert entity.sync_strategy == SyncStrategy.INCREMENTAL
|
||||
assert entity.sync_interval_minutes == 30
|
||||
|
||||
def test_pharmacy_status_enum(self):
|
||||
assert PharmacyStatus.ACTIVE.value == "active"
|
||||
assert PharmacyStatus.INACTIVE.value == "inactive"
|
||||
assert PharmacyStatus.SUSPENDED.value == "suspended"
|
||||
assert PharmacyStatus.PENDING.value == "pending"
|
||||
|
||||
|
||||
class TestMedicationEntity:
|
||||
def test_create_medication(self):
|
||||
entity = MedicationEntity(
|
||||
nregistro="12345",
|
||||
name="Ibuprofeno 400mg",
|
||||
active_ingredient="Ibuprofeno",
|
||||
dosage="400mg",
|
||||
form="Comprimidos",
|
||||
prescription_required=False,
|
||||
)
|
||||
assert entity.nregistro == "12345"
|
||||
assert entity.name == "Ibuprofeno 400mg"
|
||||
assert entity.prescription_required is False
|
||||
|
||||
|
||||
class TestConnectorEntity:
|
||||
def test_create_connector(self):
|
||||
entity = ConnectorEntity(
|
||||
erp_system_id=uuid4(),
|
||||
pharmacy_id=uuid4(),
|
||||
name="Farmatic Connector",
|
||||
connector_type=ERPType.FARMATIC,
|
||||
)
|
||||
assert entity.connector_type == ERPType.FARMATIC
|
||||
assert entity.version == "1.0.0"
|
||||
assert entity.status == ConnectorStatus.PENDING
|
||||
|
||||
|
||||
class TestReservationEntity:
|
||||
def test_create_reservation(self):
|
||||
entity = ReservationEntity(
|
||||
pharmacy_id=uuid4(),
|
||||
medication_id=uuid4(),
|
||||
quantity=5,
|
||||
idempotency_key="unique-key-123",
|
||||
)
|
||||
assert entity.quantity == 5
|
||||
assert entity.status == ReservationStatus.PENDING
|
||||
assert entity.idempotency_key == "unique-key-123"
|
||||
|
||||
|
||||
class TestAuditLogEntity:
|
||||
def test_create_audit_log(self):
|
||||
entity = AuditLogEntity(
|
||||
actor_id=uuid4(),
|
||||
actor_type="technical_user",
|
||||
action="pharmacy.create",
|
||||
resource_type="pharmacy",
|
||||
resource_id="PH-001",
|
||||
ip_address="192.168.1.1",
|
||||
)
|
||||
assert entity.action == "pharmacy.create"
|
||||
assert entity.resource_type == "pharmacy"
|
||||
|
||||
def test_enums(self):
|
||||
assert ERPType.FARMATIC.value == "farmatic"
|
||||
assert ERPType.NIXFARMA.value == "nixfarma"
|
||||
assert ERPType.UNYCOP.value == "unycop"
|
||||
assert ERPType.MOCK.value == "mock"
|
||||
assert SyncStrategy.FULL.value == "full"
|
||||
assert SyncStrategy.HYBRID.value == "hybrid"
|
||||
assert UserRole.SUPERADMIN.value == "superadmin"
|
||||
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
|
||||
from src.core.exceptions import (
|
||||
PIPException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
ValidationException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
RateLimitException,
|
||||
ServiceUnavailableException,
|
||||
ConnectorException,
|
||||
SyncException,
|
||||
)
|
||||
|
||||
|
||||
class TestPIPException:
|
||||
def test_base_exception(self):
|
||||
exc = PIPException("Something failed", code="TEST_ERROR", status_code=500)
|
||||
assert exc.message == "Something failed"
|
||||
assert exc.code == "TEST_ERROR"
|
||||
assert exc.status_code == 500
|
||||
assert "timestamp" in exc.to_dict()["error"]
|
||||
|
||||
def test_not_found(self):
|
||||
exc = NotFoundException("Pharmacy", "PH-001")
|
||||
assert exc.status_code == 404
|
||||
assert exc.code == "NOT_FOUND"
|
||||
d = exc.to_dict()
|
||||
assert "Pharmacy" in d["error"]["message"]
|
||||
|
||||
def test_conflict(self):
|
||||
exc = ConflictException("Duplicate")
|
||||
assert exc.status_code == 409
|
||||
|
||||
def test_validation(self):
|
||||
exc = ValidationException("Invalid input")
|
||||
assert exc.status_code == 422
|
||||
|
||||
def test_unauthorized(self):
|
||||
exc = UnauthorizedException()
|
||||
assert exc.status_code == 401
|
||||
|
||||
def test_forbidden(self):
|
||||
exc = ForbiddenException(required_role="admin")
|
||||
assert exc.status_code == 403
|
||||
assert exc.details["required_role"] == "admin"
|
||||
|
||||
def test_rate_limit(self):
|
||||
exc = RateLimitException()
|
||||
assert exc.status_code == 429
|
||||
|
||||
def test_service_unavailable(self):
|
||||
exc = ServiceUnavailableException("redis")
|
||||
assert exc.status_code == 503
|
||||
assert "redis" in exc.details["service"]
|
||||
|
||||
def test_connector_error(self):
|
||||
exc = ConnectorException("farmatic", "Connection refused")
|
||||
assert exc.status_code == 502
|
||||
assert exc.details["connector_type"] == "farmatic"
|
||||
|
||||
def test_sync_error(self):
|
||||
exc = SyncException("pharmacy-1", "Timeout")
|
||||
assert exc.status_code == 502
|
||||
assert exc.details["pharmacy_id"] == "pharmacy-1"
|
||||
@@ -0,0 +1,138 @@
|
||||
import asyncio
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from src.connectors.mock.mock_connector import MockConnector
|
||||
from src.connectors.base import ConnectorResult
|
||||
from src.core.exceptions import ConnectorException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connector() -> MockConnector:
|
||||
return MockConnector()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pharmacy_id() -> UUID:
|
||||
return uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def medication_id() -> UUID:
|
||||
return uuid4()
|
||||
|
||||
|
||||
class TestMockConnectorConfiguration:
|
||||
def test_default_configuration(self, mock_connector: MockConnector):
|
||||
assert mock_connector._simulate_latency is True
|
||||
assert mock_connector._error_rate == 0.0
|
||||
|
||||
def test_configure_latency(self, mock_connector: MockConnector):
|
||||
mock_connector.configure({"simulate_latency": False, "latency_range_ms": [5, 10]})
|
||||
assert mock_connector._simulate_latency is False
|
||||
assert mock_connector._latency_range_ms == (5, 10)
|
||||
|
||||
def test_configure_error_rate(self, mock_connector: MockConnector):
|
||||
mock_connector.configure({"error_rate": 0.5})
|
||||
assert mock_connector._error_rate == 0.5
|
||||
|
||||
|
||||
class TestMockConnectorGetStock:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stock_all(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
result = await mock_connector.get_stock(pharmacy_id)
|
||||
assert result.success is True
|
||||
assert "items" in result.data
|
||||
assert result.data["total"] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stock_by_nregistro(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
result = await mock_connector.get_stock(pharmacy_id, nregistro="00001")
|
||||
assert result.success is True
|
||||
assert result.data["nregistro"] == "00001"
|
||||
assert result.data["name"] == "Ibuprofeno 600mg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stock_not_found(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
result = await mock_connector.get_stock(pharmacy_id, nregistro="99999")
|
||||
assert result.success is False
|
||||
assert "not found" in result.error
|
||||
|
||||
|
||||
class TestMockConnectorReserve:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserve(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
result = await mock_connector.reserve(pharmacy_id, medication_id, 5)
|
||||
assert result.success is True
|
||||
assert result.data["status"] == "confirmed"
|
||||
assert result.data["quantity"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserve_idempotency(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
key = "idem-key-123"
|
||||
result1 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key)
|
||||
result2 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key)
|
||||
assert result1.success is True
|
||||
assert result2.success is True
|
||||
|
||||
|
||||
class TestMockConnectorCancelReservation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
reserve_result = await mock_connector.reserve(pharmacy_id, uuid4(), 2)
|
||||
reservation_id = reserve_result.data["reservation_id"]
|
||||
result = await mock_connector.cancel_reservation(pharmacy_id, reservation_id)
|
||||
assert result.success is True
|
||||
assert result.data["status"] == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_nonexistent_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
result = await mock_connector.cancel_reservation(pharmacy_id, "nonexistent-id")
|
||||
assert result.success is False
|
||||
|
||||
|
||||
class TestMockConnectorPharmacyInfo:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pharmacy_information(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False})
|
||||
result = await mock_connector.pharmacy_information(pharmacy_id)
|
||||
assert result.success is True
|
||||
assert "FARM-001" in result.data["code"]
|
||||
|
||||
|
||||
class TestMockConnectorSynchronize:
|
||||
@pytest.mark.asyncio
|
||||
async def test_synchronize(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
mock_connector.configure({"simulate_latency": False, "error_rate": 0.0})
|
||||
result = await mock_connector.synchronize(pharmacy_id, strategy="full")
|
||||
assert result.success is True
|
||||
assert result.data["items_total"] == 10
|
||||
assert result.data["items_failed"] == 0
|
||||
|
||||
|
||||
class TestMockConnectorHeartbeat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat(self, mock_connector: MockConnector, pharmacy_id: UUID):
|
||||
result = await mock_connector.heartbeat(pharmacy_id)
|
||||
assert result.success is True
|
||||
assert result.data["status"] == "healthy"
|
||||
assert result.data["connector_type"] == "mock"
|
||||
|
||||
|
||||
class TestMockConnectorErrorSimulation:
|
||||
def test_high_error_rate_triggers_exception(self, mock_connector: MockConnector):
|
||||
mock_connector.configure({"error_rate": 1.0})
|
||||
with pytest.raises(ConnectorException):
|
||||
mock_connector._maybe_error()
|
||||
|
||||
def test_zero_error_rate_no_exception(self, mock_connector: MockConnector):
|
||||
mock_connector.configure({"error_rate": 0.0})
|
||||
mock_connector._maybe_error()
|
||||
Reference in New Issue
Block a user