Frontend UX improvements: - Single-click Discord OAuth from home page (no intermediate /auth page) - Auto-redirect authenticated users from home to /games - Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout - Games page now has proper card container with shadow/border styling - Layout header includes working logout with API cookie clearing Games list enhancements: - Display team names (lname) instead of just team IDs - Show current score for each team - Show inning indicator (Top/Bot X) for active games - Responsive header with wrapped buttons on mobile Backend improvements: - Added team caching to SbaApiClient (1-hour TTL) - Enhanced GameListItem with team names, scores, inning data - Games endpoint now enriches response with SBA API team data Docker optimizations: - Optimized Dockerfile using --chown flag on COPY (faster than chown -R) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
479 lines
12 KiB
Markdown
479 lines
12 KiB
Markdown
# Plan 009: Fix Integration Test Infrastructure
|
|
|
|
**Priority**: MEDIUM
|
|
**Effort**: 2-3 days
|
|
**Status**: NOT STARTED
|
|
**Risk Level**: LOW - Testing infrastructure
|
|
|
|
---
|
|
|
|
## Problem Statement
|
|
|
|
Integration tests have infrastructure issues with AsyncPG:
|
|
- Connection conflicts with concurrent tests
|
|
- Fixture scope mismatches (module vs function)
|
|
- ~49 errors, 28 failures in integration test suite
|
|
|
|
Current workaround: Run tests individually or serially.
|
|
|
|
## Impact
|
|
|
|
- **CI/CD**: Can't run full test suite in pipeline
|
|
- **Confidence**: Integration behavior not verified automatically
|
|
- **Speed**: Serial execution is slow
|
|
|
|
## Root Causes
|
|
|
|
### 1. AsyncPG Connection Conflicts
|
|
```
|
|
asyncpg.exceptions.InterfaceError: cannot perform operation:
|
|
another operation is in progress
|
|
```
|
|
|
|
AsyncPG doesn't support concurrent operations on a single connection.
|
|
|
|
### 2. Fixture Scope Mismatches
|
|
```python
|
|
@pytest.fixture(scope="module") # Module scope
|
|
async def setup_database(event_loop): # Depends on function-scoped event_loop
|
|
```
|
|
|
|
Different scopes cause `ScopeMismatch` errors.
|
|
|
|
### 3. Shared Database State
|
|
Tests don't properly isolate database state, causing cascading failures.
|
|
|
|
## Files to Modify
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `backend/tests/conftest.py` | Fix fixture scopes |
|
|
| `backend/tests/integration/conftest.py` | Add test isolation |
|
|
| `backend/pyproject.toml` | Update pytest-asyncio config |
|
|
|
|
## Implementation Steps
|
|
|
|
### Step 1: Update pytest-asyncio Configuration (30 min)
|
|
|
|
Update `backend/pyproject.toml`:
|
|
|
|
```toml
|
|
[tool.pytest.ini_options]
|
|
asyncio_mode = "auto"
|
|
asyncio_default_fixture_loop_scope = "function" # Important!
|
|
```
|
|
|
|
This ensures each test function gets its own event loop.
|
|
|
|
### Step 2: Create Test Database Utilities (1 hour)
|
|
|
|
Create `backend/tests/utils/db_utils.py`:
|
|
|
|
```python
|
|
"""Database utilities for integration tests."""
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app.models.db_models import Base
|
|
from app.config import settings
|
|
|
|
|
|
def get_test_database_url() -> str:
|
|
"""Get test database URL (separate from dev/prod)."""
|
|
# Use a different database for tests
|
|
base_url = settings.database_url
|
|
if "strat_gameplay" in base_url:
|
|
return base_url.replace("strat_gameplay", "strat_gameplay_test")
|
|
return base_url + "_test"
|
|
|
|
|
|
def create_test_engine():
|
|
"""Create engine with NullPool (no connection pooling)."""
|
|
return create_async_engine(
|
|
get_test_database_url(),
|
|
poolclass=NullPool, # Each connection is created fresh
|
|
echo=False,
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def test_session():
|
|
"""Create isolated test session."""
|
|
engine = create_test_engine()
|
|
|
|
async with engine.begin() as conn:
|
|
# Create tables
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
AsyncTestSession = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
|
|
async with AsyncTestSession() as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
finally:
|
|
await session.close()
|
|
|
|
# Clean up
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
async def reset_database(session: AsyncSession):
|
|
"""Reset database to clean state."""
|
|
# Delete all data in reverse dependency order
|
|
for table in reversed(Base.metadata.sorted_tables):
|
|
await session.execute(table.delete())
|
|
await session.commit()
|
|
```
|
|
|
|
### Step 3: Update Integration Test Fixtures (2 hours)
|
|
|
|
Update `backend/tests/integration/conftest.py`:
|
|
|
|
```python
|
|
"""Integration test fixtures."""
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from uuid import uuid4
|
|
|
|
from tests.utils.db_utils import create_test_engine, test_session, reset_database
|
|
from app.models.db_models import Base
|
|
from app.database.operations import DatabaseOperations
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def test_db():
|
|
"""
|
|
Create fresh database for each test.
|
|
Uses NullPool to avoid connection conflicts.
|
|
"""
|
|
engine = create_test_engine()
|
|
|
|
# Create tables
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
yield engine
|
|
|
|
# Drop tables
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def db_session(test_db):
|
|
"""
|
|
Provide isolated database session for each test.
|
|
Automatically rolls back after test.
|
|
"""
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
AsyncTestSession = async_sessionmaker(
|
|
test_db,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
|
|
async with AsyncTestSession() as session:
|
|
yield session
|
|
# Rollback any uncommitted changes
|
|
await session.rollback()
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def db_ops(db_session, monkeypatch):
|
|
"""
|
|
Provide DatabaseOperations with test session.
|
|
"""
|
|
from app.database import session as db_module
|
|
|
|
# Monkeypatch to use test session
|
|
original_session = db_module.AsyncSessionLocal
|
|
|
|
# Create factory that returns our test session
|
|
async def get_test_session():
|
|
return db_session
|
|
|
|
monkeypatch.setattr(db_module, 'AsyncSessionLocal', get_test_session)
|
|
|
|
yield DatabaseOperations()
|
|
|
|
# Restore
|
|
monkeypatch.setattr(db_module, 'AsyncSessionLocal', original_session)
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def sample_game(db_ops):
|
|
"""Create sample game for testing."""
|
|
game_id = uuid4()
|
|
await db_ops.create_game({
|
|
"id": game_id,
|
|
"league_id": "sba",
|
|
"home_team_id": 1,
|
|
"away_team_id": 2,
|
|
"status": "in_progress"
|
|
})
|
|
return game_id
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def sample_lineups(db_ops, sample_game):
|
|
"""Create sample lineups for testing."""
|
|
lineups = []
|
|
for team_id in [1, 2]:
|
|
for i in range(9):
|
|
lineup = await db_ops.create_lineup({
|
|
"game_id": sample_game,
|
|
"team_id": team_id,
|
|
"card_id": 100 + (team_id * 10) + i,
|
|
"position": ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"][i],
|
|
"batting_order": i + 1 if i > 0 else None,
|
|
"is_active": True
|
|
})
|
|
lineups.append(lineup)
|
|
return lineups
|
|
```
|
|
|
|
### Step 4: Fix Specific Test Files (4 hours)
|
|
|
|
Update each integration test file to use function-scoped fixtures:
|
|
|
|
**Example: `tests/integration/database/test_operations.py`**
|
|
|
|
```python
|
|
"""Integration tests for DatabaseOperations."""
|
|
|
|
import pytest
|
|
from uuid import uuid4
|
|
|
|
class TestDatabaseOperations:
|
|
"""Tests for database CRUD operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_game(self, db_ops):
|
|
"""Test game creation."""
|
|
game_id = uuid4()
|
|
result = await db_ops.create_game({
|
|
"id": game_id,
|
|
"league_id": "sba",
|
|
"home_team_id": 1,
|
|
"away_team_id": 2,
|
|
})
|
|
|
|
assert result is not None
|
|
assert result["id"] == game_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game(self, db_ops, sample_game):
|
|
"""Test game retrieval."""
|
|
game = await db_ops.get_game(sample_game)
|
|
|
|
assert game is not None
|
|
assert game["id"] == sample_game
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_play(self, db_ops, sample_game, sample_lineups):
|
|
"""Test play creation."""
|
|
play_id = await db_ops.save_play({
|
|
"game_id": sample_game,
|
|
"play_number": 1,
|
|
"inning": 1,
|
|
"half": "top",
|
|
"outs_before": 0,
|
|
"outs_after": 1,
|
|
"batter_id": sample_lineups[0].id,
|
|
"pitcher_id": sample_lineups[9].id,
|
|
"outcome": "STRIKEOUT",
|
|
})
|
|
|
|
assert play_id is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_plays(self, db_ops, sample_game, sample_lineups):
|
|
"""Test play retrieval."""
|
|
# Create a play first
|
|
await db_ops.save_play({
|
|
"game_id": sample_game,
|
|
"play_number": 1,
|
|
# ... play data
|
|
})
|
|
|
|
plays = await db_ops.get_plays(sample_game)
|
|
|
|
assert len(plays) == 1
|
|
```
|
|
|
|
### Step 5: Add Test Isolation Markers (1 hour)
|
|
|
|
Update `backend/pyproject.toml`:
|
|
|
|
```toml
|
|
[tool.pytest.ini_options]
|
|
markers = [
|
|
"integration: marks tests as integration tests (require database)",
|
|
"slow: marks tests as slow (>1s)",
|
|
"serial: marks tests that must run serially",
|
|
]
|
|
```
|
|
|
|
Update tests that need serial execution:
|
|
|
|
```python
|
|
@pytest.mark.serial
|
|
@pytest.mark.integration
|
|
class TestGameStateRecovery:
|
|
"""Tests that must run serially due to shared state."""
|
|
pass
|
|
```
|
|
|
|
### Step 6: Create pytest Plugin for Serial Tests (1 hour)
|
|
|
|
Create `backend/tests/plugins/serial_runner.py`:
|
|
|
|
```python
|
|
"""Plugin for running serial tests in order."""
|
|
|
|
import pytest
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""Register serial marker."""
|
|
config.addinivalue_line(
|
|
"markers", "serial: mark test to run serially"
|
|
)
|
|
|
|
|
|
@pytest.hookimpl(tryfirst=True)
|
|
def pytest_collection_modifyitems(config, items):
|
|
"""Ensure serial tests run after parallel tests."""
|
|
serial_tests = []
|
|
parallel_tests = []
|
|
|
|
for item in items:
|
|
if item.get_closest_marker("serial"):
|
|
serial_tests.append(item)
|
|
else:
|
|
parallel_tests.append(item)
|
|
|
|
# Run parallel tests first, then serial
|
|
items[:] = parallel_tests + serial_tests
|
|
```
|
|
|
|
### Step 7: Update CI Configuration (30 min)
|
|
|
|
Update `.github/workflows/test.yml`:
|
|
|
|
```yaml
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
services:
|
|
postgres:
|
|
image: postgres:14
|
|
env:
|
|
POSTGRES_USER: test
|
|
POSTGRES_PASSWORD: test
|
|
POSTGRES_DB: strat_gameplay_test
|
|
options: >-
|
|
--health-cmd pg_isready
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 5
|
|
ports:
|
|
- 5432:5432
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v4
|
|
with:
|
|
python-version: '3.13'
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
cd backend
|
|
pip install -e ".[dev]"
|
|
|
|
- name: Run unit tests
|
|
run: |
|
|
cd backend
|
|
pytest tests/unit/ -v --tb=short
|
|
|
|
- name: Run integration tests
|
|
env:
|
|
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/strat_gameplay_test
|
|
run: |
|
|
cd backend
|
|
pytest tests/integration/ -v --tb=short -x
|
|
```
|
|
|
|
### Step 8: Verify Test Suite (1 hour)
|
|
|
|
```bash
|
|
cd /mnt/NV2/Development/strat-gameplay-webapp/backend
|
|
|
|
# Run unit tests (should all pass)
|
|
pytest tests/unit/ -v
|
|
|
|
# Run integration tests (should now pass)
|
|
pytest tests/integration/ -v
|
|
|
|
# Run full suite
|
|
pytest tests/ -v --tb=short
|
|
|
|
# Check for any remaining failures
|
|
pytest tests/ -v --tb=long -x # Stop on first failure
|
|
```
|
|
|
|
## Verification Checklist
|
|
|
|
- [ ] All unit tests pass (739/739)
|
|
- [ ] Integration tests pass without connection errors
|
|
- [ ] No fixture scope mismatch errors
|
|
- [ ] Tests can run in parallel with pytest-xdist
|
|
- [ ] CI pipeline passes
|
|
- [ ] Test execution time < 60 seconds
|
|
|
|
## Expected Outcome
|
|
|
|
| Metric | Before | After |
|
|
|--------|--------|-------|
|
|
| Unit tests | 739/739 ✅ | 739/739 ✅ |
|
|
| Integration tests | ~49 errors | 0 errors |
|
|
| Total time | N/A (can't run) | < 60s |
|
|
| CI status | ❌ Failing | ✅ Passing |
|
|
|
|
## Rollback Plan
|
|
|
|
If issues persist:
|
|
1. Keep serial execution as workaround
|
|
2. Skip problematic tests with `@pytest.mark.skip`
|
|
3. Run integration tests nightly instead of per-commit
|
|
|
|
## Dependencies
|
|
|
|
- None (can be implemented independently)
|
|
|
|
## Notes
|
|
|
|
- Consider using testcontainers for fully isolated DB
|
|
- May want to add pytest-xdist for parallel execution
|
|
- Future: Add database seeding scripts for complex scenarios
|