# 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
