diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..147ecbff --- /dev/null +++ b/Makefile @@ -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." diff --git a/pyproject.toml b/pyproject.toml index eefb5910..b00f29df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = ["."] diff --git a/tests/integration/README.md b/tests/integration/README.md index af85bf85..a25e1b54 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -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 -``` \ No newline at end of file + 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 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b197c38a..bc9f8d13 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -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"""