makefile + test improvements

This commit is contained in:
Shroominic
2025-08-09 12:44:21 -03:00
parent 941bb5f052
commit e5e4888dba
4 changed files with 436 additions and 66 deletions
+198
View File
@@ -0,0 +1,198 @@
# Makefile for Routstr Proxy
# Detect if we're in a virtual environment
VENV_EXISTS := $(shell test -d .venv && echo 1)
ifeq ($(VENV_EXISTS), 1)
PYTHON := .venv/bin/python
PYTEST := .venv/bin/pytest
RUFF := .venv/bin/ruff
MYPY := .venv/bin/mypy
else
PYTHON := python
PYTEST := pytest
RUFF := ruff
MYPY := mypy
endif
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps
# Default target
help:
@echo "Available targets:"
@echo " make test - Run all tests (unit + integration with mocks)"
@echo " make test-unit - Run unit tests only"
@echo " make test-integration - Run integration tests with mocks (fast)"
@echo " make test-integration-docker - Run integration tests with Docker services"
@echo " make test-all - Run all tests including Docker integration"
@echo " make test-fast - Run fast tests only (skip slow tests)"
@echo " make test-performance - Run performance tests"
@echo " make docker-up - Start Docker test services"
@echo " make docker-down - Stop Docker test services"
@echo " make clean - Clean up test artifacts and caches"
@echo " make lint - Run linting checks"
@echo " make format - Format code with ruff"
@echo " make type-check - Run mypy type checking"
@echo " make dev-setup - Set up development environment"
@echo " make check-deps - Check system dependencies"
@echo " make setup - First-time project setup"
# First-time setup
setup: check-deps dev-setup
@echo ""
@echo "🎉 Setup complete! Next steps:"
@echo " 1. Run tests: make test"
@echo " 2. Run integration: make test-integration-docker"
@echo " 3. Start developing!"
# Test targets
test: test-unit test-integration
test-unit:
@echo "🧪 Running unit tests..."
$(PYTEST) tests/unit/ -v
test-integration:
@echo "🎭 Running integration tests with mocks..."
$(PYTEST) tests/integration/ -v
test-integration-docker:
@echo "🐳 Running integration tests with Docker services..."
./tests/run_integration.py
test-all: test-unit test-integration-docker
test-fast:
@echo "⚡ Running fast tests only..."
$(PYTEST) -m "not slow and not requires_docker" -v
test-performance:
@echo "📊 Running performance tests..."
$(PYTEST) tests/integration/ -m "performance" -v -s
# Docker management
docker-up:
@echo "🚀 Starting Docker test services..."
docker-compose -f compose.testing.yml up -d
@echo "Waiting for services to be ready..."
@sleep 5
@echo "Services started. Run 'make test-integration-docker' to test."
docker-down:
@echo "🛑 Stopping Docker test services..."
docker-compose -f compose.testing.yml down -v
# Code quality
lint:
@echo "🔍 Running linting checks..."
$(RUFF) check .
$(MYPY) router/ --ignore-missing-imports
format:
@echo "✨ Formatting code..."
$(RUFF) format .
$(RUFF) check --fix .
type-check:
@echo "🔎 Running type checks..."
$(MYPY) router/ --ignore-missing-imports
# Development setup
dev-setup:
@echo "🔧 Setting up development environment..."
@# Check if uv is installed
@if ! command -v uv >/dev/null 2>&1; then \
echo "📦 uv not found. Installing uv..."; \
if command -v curl >/dev/null 2>&1; then \
curl -LsSf https://astral.sh/uv/install.sh | sh; \
elif command -v pip >/dev/null 2>&1; then \
pip install uv; \
else \
echo "❌ Neither curl nor pip found. Please install uv manually:"; \
echo " Visit https://docs.astral.sh/uv/getting-started/installation/"; \
exit 1; \
fi; \
echo "✅ uv installed successfully!"; \
else \
echo "✅ uv is already installed (version: $$(uv --version))"; \
fi
uv sync --dev
uv pip install -e .
@echo "✅ Development environment ready!"
# Check dependencies
check-deps:
@echo "🔍 Checking system dependencies..."
@echo ""
@echo "Core tools:"
@printf " %-18s" "Python:"; if command -v python >/dev/null 2>&1; then python --version; else echo "❌ Not found"; fi
@printf " %-18s" "uv:"; if command -v uv >/dev/null 2>&1; then uv --version; else echo "❌ Not found - run 'make dev-setup' to install"; fi
@printf " %-18s" "Docker:"; if command -v docker >/dev/null 2>&1; then docker --version; else echo "⚠️ Not found (optional, needed for integration tests)"; fi
@printf " %-18s" "Docker Compose:"; if command -v docker-compose >/dev/null 2>&1; then docker-compose --version; else echo "⚠️ Not found (optional, needed for integration tests)"; fi
@echo ""
@echo "Development tools:"
@printf " %-18s" "pytest:"; if $(PYTEST) --version >/dev/null 2>&1; then $(PYTEST) --version | head -1; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "ruff:"; if $(RUFF) --version >/dev/null 2>&1; then $(RUFF) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "mypy:"; if $(MYPY) --version >/dev/null 2>&1; then $(MYPY) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@echo ""
@echo "Virtual environment:"
@if [ -d ".venv" ]; then \
echo " ✅ .venv exists"; \
echo " Python: $$(.venv/bin/python --version)"; \
else \
echo " ❌ .venv not found - run 'make dev-setup'"; \
fi
@echo ""
@echo "To set up missing dependencies, run: make dev-setup"
# Cleanup
clean:
@echo "🧹 Cleaning up..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
find . -type f -name ".coverage" -delete
rm -rf htmlcov/
rm -rf dist/
rm -rf build/
rm -rf *.egg-info
@echo "✨ Cleanup complete!"
# Advanced testing options
test-coverage:
@echo "📊 Running tests with coverage..."
$(PYTEST) --cov=router --cov-report=html --cov-report=term
@echo "Coverage report generated in htmlcov/"
test-watch:
@echo "👁️ Running tests in watch mode..."
$(PYTEST)-watch
test-parallel:
@echo "🚀 Running tests in parallel..."
$(PYTEST) -n auto -v
# CI/CD specific targets
ci-test:
@echo "🤖 Running CI test suite..."
$(PYTEST) -m "not requires_docker" --tb=short -v
ci-lint:
@echo "🤖 Running CI linting..."
$(RUFF) check . --exit-non-zero-on-fix
$(MYPY) router/ --ignore-missing-imports --no-error-summary
# Debug helpers
test-debug:
@echo "🐛 Running tests with debugging enabled..."
$(PYTEST) -vvs --tb=long --pdb-trace
test-failed:
@echo "🔄 Re-running failed tests..."
$(PYTEST) --lf -v
# Performance profiling
profile:
@echo "🔥 Running with profiling..."
$(PYTHON) -m cProfile -o profile.stats -m pytest tests/integration/test_performance_load.py::TestPerformanceBaseline -v
@echo "Profile saved to profile.stats. Use '$(PYTHON) -m pstats profile.stats' to analyze."
+3 -3
View File
@@ -53,6 +53,8 @@ markers = [
"unit: marks tests as unit tests",
"slow: marks tests as slow running (deselect with '-m \"not slow\"')",
"requires_real_mint: marks tests that require a running Cashu mint instance",
"requires_docker: marks tests that require Docker services running (deselect with '-m \"not requires_docker\"')",
"performance: marks tests that measure performance metrics",
]
[tool.ruff.lint]
@@ -73,6 +75,4 @@ secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgra
routstr = { workspace = true }
[tool.uv.workspace]
members = [
".",
]
members = ["."]
+193 -17
View File
@@ -2,10 +2,43 @@
End-to-end tests for API endpoints, Cashu wallet operations, and database interactions.
## Running Tests
## Quick Start
```bash
# All integration tests
# First-time setup (installs uv if needed)
make setup
# Check if all dependencies are installed
make check-deps
# Run tests
make test
```
## Test Modes
The integration tests support two execution modes:
### 🎭 Mock Mode (Default - Fast)
- Uses in-memory mocks for external services
- No Docker required
- Runs quickly, ideal for CI/CD
- Good for rapid development iteration
### 🐳 Docker Mode (Realistic)
- Uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
- More accurate testing environment
- Slower but catches more edge cases
- Recommended before releases
## Running Tests
### Quick Mode (Mocked Services)
```bash
# All integration tests with mocks
pytest tests/integration/ -v
# Specific test file
@@ -13,40 +46,183 @@ pytest tests/integration/test_wallet_topup.py -v
# Skip slow tests
pytest tests/integration/ -m "not slow" -v
# Run only unit-style integration tests
pytest tests/integration/ -m "not requires_docker" -v
```
### Full Integration Mode (Docker Services)
```bash
# Using the automated script (recommended)
./tests/run_integration.py
# Or manually:
docker-compose -f compose.testing.yml up -d
USE_LOCAL_SERVICES=1 pytest tests/integration/ -v
docker-compose -f compose.testing.yml down -v
```
### CI/CD Mode
```bash
# Fast tests only for continuous integration
pytest tests/integration/ -m "not slow and not requires_docker" -v
# Performance tests
pytest tests/integration/ -m "performance" -v
```
## Test Infrastructure
**TestmintWallet** - Mock Cashu wallet for generating test tokens
**DatabaseSnapshot** - Captures database state changes
**Test Utilities** - Validators for responses, performance, and concurrency
### Core Fixtures
## Real Testmint Setup (Optional)
- **`integration_client`** - Async HTTP client configured for testing
- **`authenticated_client`** - Pre-authenticated client with API key
- **`testmint_wallet`** - Mock/real Cashu wallet for token generation
- **`db_snapshot`** - Database state tracking for verification
- **`test_mode`** - Reports current execution mode (mock/docker)
By default, tests use a mock testmint. For testing against a real instance:
### Utility Classes
```bash
./tests/integration/setup_testmint.sh
export USE_REAL_MINT=true
export MINT_URL=http://localhost:3338
pytest tests/integration/ -v
```
- **`ResponseValidator`** - Validates API response formats
- **`PerformanceValidator`** - Tracks and validates performance metrics
- **`ConcurrencyTester`** - Tests concurrent request handling
- **`CashuTokenGenerator`** - Generates valid/invalid test tokens
## Environment Configuration
Test environment configuration is handled directly in `conftest.py`. The configuration automatically switches between:
- **Mock mode**: Fast, uses mocked services (default)
- **Docker mode**: Uses real Docker services when `USE_LOCAL_SERVICES=1`
This keeps all test configuration in one place and avoids file duplication.
## Writing Tests
### Basic Test Structure
```python
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_topup(integration_client, testmint_wallet, db_snapshot):
async def test_wallet_topup(
authenticated_client: AsyncClient,
testmint_wallet: Any,
db_snapshot: Any
):
# Capture initial state
await db_snapshot.capture()
# Generate test token
token = await testmint_wallet.mint_tokens(1000)
response = await integration_client.post(
# Make API request
response = await authenticated_client.post(
"/v1/wallet/topup",
params={"cashu_token": token}
)
# Validate response
assert response.status_code == 200
# Verify database changes
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 1
```
assert len(diff["api_keys"]["modified"]) == 1
```
### Testing Concurrent Operations
```python
async def test_concurrent_topups(
integration_client: AsyncClient,
testmint_wallet: Any,
create_api_key: Callable
):
# Create multiple API keys
keys = []
for i in range(5):
key, _ = await create_api_key(integration_client, testmint_wallet)
keys.append(key)
# Test concurrent requests
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client,
[{"method": "GET", "url": "/v1/wallet/",
"headers": {"Authorization": f"Bearer {key}"}}
for key in keys],
max_concurrent=5
)
# All should succeed
assert all(r.status_code == 200 for r in responses)
```
### Performance Testing
```python
@pytest.mark.performance
async def test_endpoint_performance(
authenticated_client: AsyncClient,
performance_validator: PerformanceValidator
):
# Run multiple requests
for i in range(100):
start = performance_validator.start_timing("wallet_info")
response = await authenticated_client.get("/v1/wallet/")
performance_validator.end_timing("wallet_info", start)
# Validate 95th percentile < 100ms
result = performance_validator.validate_response_time(
"wallet_info", max_duration=0.1, percentile=0.95
)
assert result["valid"], f"P95: {result['percentile_time']:.3f}s"
```
## Troubleshooting
### Tests Failing with Connection Errors
- Ensure Docker services are running: `docker ps`
- Check service logs: `docker-compose -f compose.testing.yml logs`
- Verify ports aren't in use: `lsof -i :3338,3000,8000,8088`
### Mock vs Docker Mode Confusion
- Check current mode: Look for 🎭 or 🐳 emoji in test output
- Force mock mode: Unset `USE_LOCAL_SERVICES`
- Force Docker mode: `export USE_LOCAL_SERVICES=1`
### Slow Test Execution
- Use mock mode for development: `pytest tests/integration/`
- Skip slow tests: `pytest -m "not slow"`
- Run specific test files only
- Use pytest-xdist for parallel execution: `pytest -n auto`
### Installing uv Manually
If `make dev-setup` fails to install uv automatically:
```bash
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or with pip
pip install uv
# Or with Homebrew
brew install uv
```
## Best Practices
1. **Use Mock Mode for Development** - It's fast and catches most issues
2. **Run Docker Mode Before PRs** - Ensures realistic testing
3. **Add Appropriate Markers** - Help others run relevant test subsets
- Use `@pytest.mark.slow` for tests that take significant time (e.g., memory/load tests)
- Use `@pytest.mark.requires_docker` for tests needing Docker services
4. **Verify Database State** - Use `db_snapshot` for state verification
5. **Test Edge Cases** - Invalid inputs, network failures, race conditions
6. **Monitor Performance** - Add performance tests for critical paths
+42 -46
View File
@@ -15,18 +15,19 @@ from router.core.logging import get_logger
logger = get_logger(__name__)
# Configure environment based on whether we're using local services or not
# Configure test environment based on whether we're using local services or not
use_local_services = os.environ.get("USE_LOCAL_SERVICES", "0") == "1"
if use_local_services:
# Use local Docker services for integration tests
# Docker mode: Use Docker services for more realistic testing
logger.info("🐳 Using Docker services for integration tests")
test_env = {
"DATABASE_URL": "sqlite+aiosqlite:///:memory:",
"UPSTREAM_BASE_URL": "http://localhost:3000", # Mock OpenAI service
"UPSTREAM_API_KEY": "test-upstream-key",
"CASHU_MINTS": "http://mint:3338", # Mock Cashu mint (Docker service name)
"MINT": "http://mint:3338", # Fallback mint URL (Docker service name)
"MINT_URL": "http://mint:3338", # Another fallback (Docker service name)
"CASHU_MINTS": "http://mint:3338", # Docker service name for router validation
"MINT": "http://mint:3338",
"MINT_URL": "http://mint:3338",
"NOSTR_RELAY_URL": "ws://localhost:8088",
"RECEIVE_LN_ADDRESS": "test@routstr.com",
"REFUND_PROCESSING_INTERVAL": "3600",
@@ -43,12 +44,13 @@ if use_local_services:
"CORS_ORIGINS": "*",
}
else:
# Use mock/in-memory services for unit-style integration tests
# Mock mode: Use in-memory mocks for fast testing
logger.info("🎭 Using mocked services for integration tests")
test_env = {
"DATABASE_URL": "sqlite+aiosqlite:///:memory:",
"UPSTREAM_BASE_URL": "https://api.openai.com/v1",
"UPSTREAM_API_KEY": "test-upstream-key",
"CASHU_MINTS": "http://localhost:3338", # Use test mint URL
"CASHU_MINTS": "http://localhost:3338",
"RECEIVE_LN_ADDRESS": "test@routstr.com",
"REFUND_PROCESSING_INTERVAL": "3600",
"NSEC": "nsec1testkey1234567890abcdef",
@@ -65,6 +67,17 @@ from router.core.db import ApiKey, get_session # noqa: E402
from router.core.main import app, lifespan # noqa: E402
@pytest.fixture(scope="session")
def test_mode() -> str:
"""Returns current test mode for clarity"""
if os.environ.get("USE_LOCAL_SERVICES") == "1":
print("\n🐳 Running with Docker services (realistic mode)")
return "docker"
else:
print("\n🎭 Running with mocked services (fast mode)")
return "mock"
class TestmintWallet:
"""Test wallet that simulates Cashu mint interactions for testing"""
@@ -206,7 +219,7 @@ class TestmintWallet:
total_amount = 0
mint_url = self.mint_url
unit = token_data.get("unit", "sat")
for mint_tokens in token_data["token"]:
mint_url = mint_tokens.get("mint", self.mint_url)
for proof in mint_tokens["proofs"]:
@@ -221,7 +234,7 @@ class TestmintWallet:
except Exception as e:
raise ValueError(f"Failed to decode token: {str(e)}")
async def redeem_token_simple(self, token: str) -> Tuple[int, str]:
"""Redeem a Cashu token - simple version for credit_balance"""
amount, unit, mint_url = await self.redeem_token(token)
@@ -235,7 +248,9 @@ class TestmintWallet:
# For testing, create a refund token
return await self.mint_tokens(amount)
async def send_token(self, amount: int, unit: str, mint_url: Optional[str] = None) -> str:
async def send_token(
self, amount: int, unit: str, mint_url: Optional[str] = None
) -> str:
"""Send token with compatible signature for mocking router.wallet.send_token"""
return await self.send(amount)
@@ -266,8 +281,10 @@ class TestmintWallet:
) -> int:
"""Credit balance to API key - test implementation"""
try:
logger.info(f"TestmintWallet.credit_balance called with token: {cashu_token[:20]}...")
logger.info(
f"TestmintWallet.credit_balance called with token: {cashu_token[:20]}..."
)
# Redeem the token to get amount
amount, _ = await self.redeem_token_simple(cashu_token)
logger.info(f"TestmintWallet.credit_balance redeemed amount: {amount}")
@@ -278,7 +295,7 @@ class TestmintWallet:
# Credit the balance using atomic database update to prevent race conditions
from sqlmodel import col, update
# Use atomic update to avoid lost update problem in concurrent scenarios
stmt = (
update(ApiKey)
@@ -287,17 +304,22 @@ class TestmintWallet:
)
await session.execute(stmt)
await session.commit()
# Refresh the key object to get the updated balance
await session.refresh(key)
logger.info(f"TestmintWallet.credit_balance successfully credited {amount_msat} msat")
logger.info(
f"TestmintWallet.credit_balance successfully credited {amount_msat} msat"
)
return amount_msat
except Exception as e:
logger.error(f"TestmintWallet.credit_balance failed: {e}")
import traceback
logger.error(f"TestmintWallet.credit_balance full traceback: {traceback.format_exc()}")
logger.error(
f"TestmintWallet.credit_balance full traceback: {traceback.format_exc()}"
)
raise ValueError(f"Failed to redeem token: {str(e)}")
@@ -483,10 +505,9 @@ async def integration_app(
yield test_app
else:
# Use testmint with wallet patches for all integration tests
mint_url = test_env.get("CASHU_MINTS", "http://localhost:3338")
mint_url = os.environ.get("CASHU_MINTS", "http://localhost:3338")
with (
patch("router.core.db.engine", integration_engine),
patch.dict(os.environ, test_env, clear=False),
patch("router.wallet.TRUSTED_MINTS", [mint_url]),
patch("router.wallet.PRIMARY_MINT_URL", mint_url),
patch("router.auth.credit_balance", testmint_wallet.credit_balance),
@@ -503,9 +524,9 @@ async def integration_app(
# Configure the WebSocket mock for discovery service - fast failure for performance tests
async def mock_websocket_connect(*args: Any, **kwargs: Any) -> None:
raise ConnectionError("Mock connection failed")
mock_websockets.side_effect = mock_websocket_connect
yield test_app
@@ -637,31 +658,6 @@ def mock_upstream_server() -> Any:
return mock_server
@pytest.fixture
def integration_env_vars() -> Any:
"""Fixture to manage integration test environment variables"""
original_env = os.environ.copy()
# Set integration test specific environment variables
test_env = {
"TESTMINT_URL": "https://testmint.routstr.com",
"INTEGRATION_TEST": "true",
"LOG_LEVEL": "DEBUG",
"DATABASE_POOL_SIZE": "10",
"DATABASE_MAX_OVERFLOW": "20",
"REQUEST_TIMEOUT": "30",
"UPSTREAM_TIMEOUT": "25",
}
os.environ.update(test_env)
yield test_env
# Restore original environment
os.environ.clear()
os.environ.update(original_env)
@pytest_asyncio.fixture
async def background_tasks_controller() -> AsyncGenerator[Any, None]:
"""Control background tasks during tests"""