Files
webber/webber-api/tests/conftest.py
T
jpmschweitzerandClaude eb3467d06a fix(webber-api): clear ruff, and two things it was pointing at
97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10
unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both
were visible only because the lint made me look.

`webber version` did not exist. src/cli/commands/version.py defines
show_version(), main.py imported it, and the registration line was never
written — the CLI exposed chat and explore only. The import carried
`# noqa: F401`, which is what kept the omission quiet: someone marked the
symptom as intentional instead of asking why it was unused. show_version is not
redundant with the --version flag; it prints the resolved Ollama URL, model and
debug state, which is the form worth having when something is misconfigured.
Registered, and the suppression dropped because the import is now genuinely used.

test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched
get_agent, and stopped at the comment "For now, verify the explore agent would
be called correctly". It had been counted as a passing test. An AST sweep of all
238 test functions found it was the only one, which is worth knowing — the
problem was contained, not systemic. It is now skipped with a reason, so it
reports as unfinished rather than as passing. Reducing it rather than deleting
its imports was the point: tidying the imports would have made a hollow test
look clean.

Two findings were false positives, and both are recorded rather than silently
worked around:

B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is
awaited at line 326 before `continue` reaches the next iteration, so neither
name can be rebound while the closure is pending, and the exception path
cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays
true if the await ever moves. I had called it a live bug before tracing it,
which is the mistake Rule 5 exists for.

RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its
suggested fix — annotate ClassVar — would remove the field from the model.
ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per
instance; verified by constructing two and confirming their lists are distinct
objects. Suppressed with that evidence in the comment. Ruff cannot see the
pydantic base because BaseSchema is a local subclass of BaseModel.

Also moved a stray `from src.shared.logging import ...` that had drifted below
a function definition, and merged a nested if in the ollama provider.

215 passed, 23 skipped, unchanged except for the new skip. `webber version`
exercised end to end.

mypy is NOT addressed here and the gate still fails on it — 55 errors in 14
files, 35 of them no-any-return from pydantic_ai's untyped returns. That was
hidden behind ruff, because the gate stops at the first failing stage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 15:05:02 +02:00

343 lines
8.7 KiB
Python

"""
Pytest configuration and fixtures.
Test categories:
- Unit tests: Run by default, no external dependencies
- Integration tests: Require Ollama, run with --run-integration
- E2E tests: Require running API server, run with --run-e2e
Usage:
pytest tests/ # Run unit tests only
pytest tests/ --run-integration # Include integration tests
pytest tests/ --run-e2e # Include E2E tests
pytest tests/ --run-integration --run-e2e # Run all tests
"""
import os
import tempfile
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from src.main import app
# =============================================================================
# Command Line Options
# =============================================================================
def pytest_addoption(parser):
"""Add custom command line options."""
parser.addoption(
"--run-integration",
action="store_true",
default=False,
help="Run integration tests (require Ollama to be running)",
)
parser.addoption(
"--run-e2e",
action="store_true",
default=False,
help="Run E2E tests (require API server to be running)",
)
parser.addoption(
"--ollama-url",
action="store",
default="http://192.168.86.149:11434",
help="Ollama API URL for integration tests",
)
parser.addoption(
"--api-url",
action="store",
default="http://localhost:8095",
help="Webber API URL for E2E tests",
)
def pytest_collection_modifyitems(config, items):
"""Skip integration/e2e tests unless explicitly requested."""
skip_integration = pytest.mark.skip(reason="need --run-integration option to run")
skip_e2e = pytest.mark.skip(reason="need --run-e2e option to run")
for item in items:
if "integration" in item.keywords and not config.getoption("--run-integration"):
item.add_marker(skip_integration)
if "e2e" in item.keywords and not config.getoption("--run-e2e"):
item.add_marker(skip_e2e)
# =============================================================================
# Basic Fixtures
# =============================================================================
@pytest.fixture
def anyio_backend():
"""Use asyncio for async tests."""
return "asyncio"
@pytest.fixture
async def client():
"""Async HTTP client for testing (no auth)."""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as ac:
yield ac
@pytest.fixture
async def auth_client():
"""Async HTTP client with API key for authenticated requests."""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"X-API-Key": "test-api-key"}
) as ac:
yield ac
# =============================================================================
# Integration Test Fixtures
# =============================================================================
@pytest.fixture
def ollama_url(request):
"""Get Ollama URL from command line or environment."""
return request.config.getoption("--ollama-url") or os.environ.get(
"OLLAMA_URL", "http://192.168.86.149:11434"
)
@pytest.fixture
def api_url(request):
"""Get API URL from command line or environment."""
return request.config.getoption("--api-url") or os.environ.get(
"WEBBER_API_URL", "http://localhost:8095"
)
@pytest.fixture
def sample_project():
"""Create a sample Python project for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
# Create a realistic project structure
(project / "src").mkdir()
(project / "tests").mkdir()
# Main application file
(project / "src" / "__init__.py").write_text("")
(project / "src" / "main.py").write_text('''"""Main application module."""
def greet(name: str) -> str:
"""Greet someone by name.
Args:
name: The name to greet
Returns:
A greeting string
"""
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
"""Add two numbers.
Args:
a: First number
b: Second number
Returns:
Sum of a and b
"""
return a + b
def divide(a: float, b: float) -> float:
"""Divide two numbers.
Args:
a: Dividend
b: Divisor
Returns:
Result of a / b
Raises:
ValueError: If b is zero
"""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
if __name__ == "__main__":
print(greet("World"))
''')
# Utility module
(project / "src" / "utils.py").write_text('''"""Utility functions."""
def is_even(n: int) -> bool:
"""Check if a number is even."""
return n % 2 == 0
def is_prime(n: int) -> bool:
"""Check if a number is prime."""
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
def factorial(n: int) -> int:
"""Calculate factorial recursively."""
if n <= 1:
return 1
return n * factorial(n - 1)
def fibonacci(n: int) -> list[int]:
"""Generate Fibonacci sequence up to n terms."""
if n <= 0:
return []
if n == 1:
return [0]
fib = [0, 1]
for _ in range(2, n):
fib.append(fib[-1] + fib[-2])
return fib
''')
# Test file
(project / "tests" / "__init__.py").write_text("")
(project / "tests" / "test_main.py").write_text('''"""Tests for main module."""
import pytest
from src.main import greet, add, divide
def test_greet():
assert greet("World") == "Hello, World!"
def test_add():
assert add(2, 3) == 5
def test_divide():
assert divide(10, 2) == 5.0
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1, 0)
''')
# README
(project / "README.md").write_text('''# Sample Project
A simple Python project for testing Webber's code exploration.
## Features
- Greeting functionality
- Math utilities
- Comprehensive test suite
## Usage
```python
from src.main import greet, add
print(greet("World"))
print(add(2, 3))
```
## Testing
```bash
pytest tests/
```
''')
# Configuration files
(project / "pyproject.toml").write_text('''[project]
name = "sample-project"
version = "0.1.0"
[tool.pytest.ini_options]
testpaths = ["tests"]
''')
yield project
@pytest.fixture
def sample_project_with_bug():
"""Create a sample project with intentional bugs for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
project = Path(tmpdir)
(project / "buggy.py").write_text('''"""Module with intentional bugs."""
def divide_numbers(a, b):
"""Divide two numbers - BUG: no zero check."""
return a / b # BUG: ZeroDivisionError if b is 0
def get_item(lst, index):
"""Get item from list - BUG: no bounds check."""
return lst[index] # BUG: IndexError if out of bounds
def parse_int(s):
"""Parse string to int - BUG: no error handling."""
return int(s) # BUG: ValueError if not a valid int
# TODO: Fix the division bug
# FIXME: Add bounds checking to get_item
''')
yield project
# =============================================================================
# E2E Test Fixtures
# =============================================================================
@pytest.fixture
async def live_client(api_url):
"""HTTP client for E2E tests against running server."""
async with AsyncClient(base_url=api_url, timeout=30.0) as client:
yield client
# =============================================================================
# Helper Functions
# =============================================================================
def assert_contains_any(text: str, substrings: list[str], case_sensitive: bool = False) -> bool:
"""Assert that text contains at least one of the substrings."""
check_text = text if case_sensitive else text.lower()
check_subs = substrings if case_sensitive else [s.lower() for s in substrings]
found = [s for s in check_subs if s in check_text]
assert found, f"Expected text to contain one of {substrings}, but none found in: {text[:200]}..."
return True
def assert_contains_all(text: str, substrings: list[str], case_sensitive: bool = False) -> bool:
"""Assert that text contains all of the substrings."""
check_text = text if case_sensitive else text.lower()
check_subs = substrings if case_sensitive else [s.lower() for s in substrings]
missing = [s for s in check_subs if s not in check_text]
assert not missing, f"Expected text to contain all of {substrings}, missing: {missing}"
return True