FastAPI resolves Depends(get_task_executor) against the function object it
captured when each route was decorated, at import time. patch('src.main.
get_task_executor') therefore never reaches an already-registered route —
confirmed empirically with a minimal FastAPI app before touching this repo's
tests. Every one of these tests fell through to the real dependency, which
called the real psycopg2.connect() and failed on postgres-shared's DNS (a
Docker-network name unreachable from a host process either way, so this
reproduced identically with and without host networking).
app.dependency_overrides is FastAPI's own supported mechanism for this, and
was already used correctly elsewhere in this suite (test_task_delete.py).
Added a shared override_task_executor fixture in conftest.py and switched
every get_task_executor patch() call site in test_api_comprehensive.py to it.
Source (src/main.py) is unchanged — Depends() is the idiomatic, correct
pattern; the tests were using an ineffective substitute for it.
240 lines
6.9 KiB
Python
240 lines
6.9 KiB
Python
"""
|
|
Shared pytest fixtures for The Scheduler tests.
|
|
"""
|
|
import asyncio
|
|
import os
|
|
from typing import Generator, AsyncGenerator
|
|
from unittest.mock import Mock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from httpx import AsyncClient, ASGITransport
|
|
|
|
# Set test environment variables before importing app
|
|
os.environ["POSTGRES_HOST"] = "postgres-shared" # Use real postgres for integration tests
|
|
os.environ["POSTGRES_DB"] = "test_scheduler"
|
|
os.environ["POSTGRES_USER"] = "test_scheduler_user"
|
|
os.environ["POSTGRES_PASSWORD"] = "test_password_12345"
|
|
os.environ["SCHEDULER_API_KEY"] = "test-api-key-12345"
|
|
os.environ["GITEA_USER"] = "test-librarian"
|
|
os.environ["GITEA_PASSWORD"] = "test-gitea-token"
|
|
os.environ["REDIS_HOST"] = "redis-shared"
|
|
|
|
from src.main import app, get_task_executor
|
|
from src.config import Settings, get_settings
|
|
|
|
|
|
# Override settings for tests
|
|
@pytest.fixture
|
|
def test_settings() -> Settings:
|
|
"""Provide test settings loaded from environment variables."""
|
|
# Settings are already loaded from environment (set at module level)
|
|
return get_settings()
|
|
|
|
|
|
@pytest.fixture
|
|
def api_key() -> str:
|
|
"""Test API key for authenticated requests."""
|
|
return "test-api-key-12345"
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_headers(api_key: str) -> dict:
|
|
"""Authentication headers for test requests."""
|
|
return {"Authorization": f"Bearer {api_key}"}
|
|
|
|
|
|
# FastAPI resolves `Depends(get_task_executor)` against the function object it
|
|
# captured when each route was decorated, at import time. `unittest.mock.patch`
|
|
# on the module attribute `src.main.get_task_executor` therefore never reaches
|
|
# an already-registered route — the route keeps calling the original function.
|
|
# `app.dependency_overrides` is FastAPI's own supported mechanism for this
|
|
# (already used correctly in test_task_delete.py); this fixture centralizes it
|
|
# so call sites just need the executor mock they want installed.
|
|
@pytest.fixture
|
|
def override_task_executor() -> Generator[MagicMock, None, None]:
|
|
"""Install a mock TaskExecutor as the live dependency for this test only."""
|
|
mock_executor = MagicMock()
|
|
app.dependency_overrides[get_task_executor] = lambda: mock_executor
|
|
yield mock_executor
|
|
app.dependency_overrides.pop(get_task_executor, None)
|
|
|
|
|
|
# Synchronous test client
|
|
@pytest.fixture
|
|
def client() -> Generator[TestClient, None, None]:
|
|
"""
|
|
FastAPI test client for synchronous tests.
|
|
Note: Some endpoints may fail if they require real database connections.
|
|
"""
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
# Async test client
|
|
@pytest.fixture
|
|
async def async_client() -> AsyncGenerator[AsyncClient, None]:
|
|
"""
|
|
Async HTTP client for testing async endpoints.
|
|
Note: Some endpoints may fail if they require real database connections.
|
|
"""
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://test"
|
|
) as ac:
|
|
yield ac
|
|
|
|
|
|
# Mock database connection
|
|
@pytest.fixture
|
|
def mock_db_connection():
|
|
"""Mock database connection for testing without real database."""
|
|
mock_conn = MagicMock()
|
|
mock_cursor = MagicMock()
|
|
|
|
# Setup context managers
|
|
mock_conn.__enter__ = Mock(return_value=mock_conn)
|
|
mock_conn.__exit__ = Mock(return_value=None)
|
|
mock_conn.cursor.return_value.__enter__ = Mock(return_value=mock_cursor)
|
|
mock_conn.cursor.return_value.__exit__ = Mock(return_value=None)
|
|
|
|
return mock_conn, mock_cursor
|
|
|
|
|
|
# Mock scheduler
|
|
@pytest.fixture
|
|
def mock_scheduler():
|
|
"""Mock APScheduler for testing without real scheduler."""
|
|
mock = MagicMock()
|
|
mock.running = True
|
|
mock.get_jobs.return_value = []
|
|
return mock
|
|
|
|
|
|
# Mock task executor
|
|
@pytest.fixture
|
|
def mock_task_executor():
|
|
"""Mock TaskExecutor for testing without real executor."""
|
|
mock = MagicMock()
|
|
return mock
|
|
|
|
|
|
# Sample task data
|
|
@pytest.fixture
|
|
def sample_task_data() -> dict:
|
|
"""Sample task data for testing."""
|
|
return {
|
|
"id": 1,
|
|
"task_name": "test_task",
|
|
"service": "scheduler",
|
|
"executor": "example_executor",
|
|
"priority": 50,
|
|
"minute": 0,
|
|
"hour": 4,
|
|
"day_of_month": -1,
|
|
"month": -1,
|
|
"day_of_week": -1,
|
|
"enabled": True,
|
|
"description": "Test task for unit tests",
|
|
"config": {
|
|
"message": "Test message",
|
|
"delay_seconds": 1
|
|
},
|
|
"max_retries": 3,
|
|
"timeout_seconds": 60,
|
|
"retry_count": 0,
|
|
"last_run": None,
|
|
"last_status": None,
|
|
"last_duration_seconds": None,
|
|
"created_at": "2025-12-07T00:00:00",
|
|
"updated_at": "2025-12-07T00:00:00",
|
|
"created_by": "test"
|
|
}
|
|
|
|
|
|
# Sample doc sync config
|
|
@pytest.fixture
|
|
def sample_doc_sync_config() -> dict:
|
|
"""Sample doc sync configuration for testing."""
|
|
return {
|
|
"project": "test-project",
|
|
"upstream_repo": "https://github.com/test/repo.git",
|
|
"docs_paths": ["/docs"],
|
|
"gitea_repo": "library/test-docs",
|
|
"branch": "main"
|
|
}
|
|
|
|
|
|
# Temporary directory for file operations
|
|
@pytest.fixture
|
|
def temp_work_dir(tmp_path):
|
|
"""Temporary directory for testing file operations."""
|
|
work_dir = tmp_path / "test-work"
|
|
work_dir.mkdir()
|
|
return work_dir
|
|
|
|
|
|
# Database fixtures for integration tests
|
|
@pytest.fixture(scope="function")
|
|
def db_connection():
|
|
"""Provide a database connection for integration tests."""
|
|
import psycopg2
|
|
|
|
conn = psycopg2.connect(
|
|
host="postgres-shared",
|
|
database="test_scheduler",
|
|
user="test_scheduler_user",
|
|
password="test_password_12345"
|
|
)
|
|
yield conn
|
|
|
|
# Cleanup: rollback any uncommitted changes
|
|
conn.rollback()
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def clean_database(db_connection):
|
|
"""Clean test database before each test."""
|
|
cursor = db_connection.cursor()
|
|
|
|
# Delete all test data
|
|
cursor.execute("DELETE FROM task_executions")
|
|
cursor.execute("DELETE FROM scheduled_tasks")
|
|
db_connection.commit()
|
|
|
|
yield db_connection
|
|
|
|
# Cleanup after test
|
|
cursor.execute("DELETE FROM task_executions")
|
|
cursor.execute("DELETE FROM scheduled_tasks")
|
|
db_connection.commit()
|
|
cursor.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_task_in_db(clean_database):
|
|
"""Insert a sample task into the test database."""
|
|
cursor = clean_database.cursor()
|
|
|
|
cursor.execute("""
|
|
INSERT INTO scheduled_tasks
|
|
(task_name, service, executor, priority, minute, hour,
|
|
day_of_month, month, day_of_week, enabled, description,
|
|
config, max_retries, timeout_seconds, created_by)
|
|
VALUES
|
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
|
|
RETURNING id
|
|
""", (
|
|
'test_task', 'scheduler', 'example_executor', 50,
|
|
-1, -1, -1, -1, -1, True, 'Test task',
|
|
'{"message": "Test", "delay_seconds": 0}',
|
|
3, 60, 'test'
|
|
))
|
|
|
|
task_id = cursor.fetchone()[0]
|
|
clean_database.commit()
|
|
cursor.close()
|
|
|
|
return task_id
|