tests/conftest.py sets os.environ["POSTGRES_HOST"] = "postgres-shared" at module level (before `from src.main import app`), commented "Use real postgres for integration tests". test_settings_loads_from_environment asserted settings.postgres_host == "test-postgres", a value grep confirms nothing in this suite has ever set — git log -p shows both the conftest line and this assertion originate in the same single commit and neither has changed since. Matched the assertion to the environment the suite actually runs under. Not a source change and not a claim that "postgres-shared" is the right fixture value for a suite this ticket also found is not actually hermetic where that value is concerned (see the 12 pre-existing DNS errors, tracked separately from this fix) — only that the assertion should test what the fixture sets, not an unset value.
201 lines
7.4 KiB
Python
201 lines
7.4 KiB
Python
"""
|
|
Integration tests with database.
|
|
These tests can be run with a test database or skipped if not available.
|
|
"""
|
|
import pytest
|
|
import os
|
|
from fastapi.testclient import TestClient
|
|
from unittest.mock import patch
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.skipif(
|
|
os.getenv("RUN_INTEGRATION_TESTS") != "true",
|
|
reason="Integration tests require RUN_INTEGRATION_TESTS=true"
|
|
)
|
|
class TestDatabaseIntegration:
|
|
"""Integration tests that use actual database."""
|
|
|
|
def test_health_with_real_scheduler(self, client: TestClient):
|
|
"""Test health endpoint with real scheduler instance."""
|
|
# This test runs against the actual scheduler if it's running
|
|
# For true integration testing, we'd set up a test database
|
|
pass
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestEndToEndTaskFlow:
|
|
"""End-to-end tests for task lifecycle (mocked database)."""
|
|
|
|
def test_create_list_delete_task_flow(self, client: TestClient, auth_headers: dict, sample_task_data: dict, override_task_executor):
|
|
"""Test complete task lifecycle: create → list → delete."""
|
|
from unittest.mock import MagicMock
|
|
|
|
# This simulates the full flow with mocked database
|
|
mock_conn = MagicMock()
|
|
mock_cursor = MagicMock()
|
|
|
|
# Mock create. list_tasks (GET /tasks) uses fetchall, not fetchone, so
|
|
# it does not consume a slot here — the previous list of 3 values was
|
|
# sized for a delete that has since grown a second lookup (T-97's
|
|
# 409-on-history-loss check, c34db66): delete_task now does
|
|
# `row = cur.fetchone()` for the task id, then a separate
|
|
# `cur.fetchone()[0]` for its execution count, so a real
|
|
# create->list->delete flow needs 1 (create) + 2 (delete) = 3
|
|
# fetchone() calls in that order, not create+list+delete.
|
|
created_task = {**sample_task_data, "id": 99}
|
|
mock_cursor.fetchone.side_effect = [
|
|
created_task, # create_task: INSERT ... RETURNING (dict row)
|
|
(99,), # delete_task: SELECT id FROM scheduled_tasks
|
|
(0,), # delete_task: SELECT COUNT(*) FROM task_executions — none, so it proceeds
|
|
]
|
|
mock_cursor.fetchall.return_value = [created_task]
|
|
|
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None)
|
|
override_task_executor.get_db_connection.return_value.__enter__ = MagicMock(return_value=mock_conn)
|
|
override_task_executor.get_db_connection.return_value.__exit__ = MagicMock(return_value=None)
|
|
|
|
# Create
|
|
create_response = client.post(
|
|
"/tasks",
|
|
headers=auth_headers,
|
|
json=sample_task_data
|
|
)
|
|
|
|
# List
|
|
list_response = client.get("/tasks", headers=auth_headers)
|
|
|
|
# Delete
|
|
delete_response = client.delete(
|
|
"/tasks/test_task",
|
|
headers=auth_headers
|
|
)
|
|
|
|
# Verify the flow worked
|
|
assert create_response.status_code in [200, 500] # May fail on DB issues
|
|
assert list_response.status_code in [200, 500]
|
|
assert delete_response.status_code in [200, 404, 500]
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestTaskExecutorIntegration:
|
|
"""Integration tests for task execution."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_example_task_real(self, test_settings):
|
|
"""Test executing example executor with real implementation."""
|
|
from src.executors import example_executor
|
|
|
|
config = {
|
|
"message": "Integration test",
|
|
"delay_seconds": 0
|
|
}
|
|
|
|
result = await example_executor.execute(config, test_settings)
|
|
|
|
assert "Integration test" in result
|
|
assert "took" in result.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_scheduling_logic(self, test_settings):
|
|
"""Test task scheduling logic."""
|
|
from src.tasks.executor import TaskExecutor
|
|
from datetime import datetime
|
|
|
|
executor = TaskExecutor(test_settings)
|
|
|
|
# Test various scheduling scenarios
|
|
task_every_minute = {
|
|
'minute': -1, 'hour': -1, 'day_of_month': -1,
|
|
'month': -1, 'day_of_week': -1
|
|
}
|
|
|
|
task_specific_time = {
|
|
'minute': 30, 'hour': 14, 'day_of_month': -1,
|
|
'month': -1, 'day_of_week': -1
|
|
}
|
|
|
|
now = datetime(2025, 12, 7, 14, 30, 0)
|
|
|
|
# Every minute task should always run
|
|
assert executor._should_run_now(task_every_minute, now) is True
|
|
|
|
# Specific time task should run at 14:30
|
|
assert executor._should_run_now(task_specific_time, now) is True
|
|
|
|
# But not at 14:31
|
|
now_plus_one = datetime(2025, 12, 7, 14, 31, 0)
|
|
assert executor._should_run_now(task_specific_time, now_plus_one) is False
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestErrorHandling:
|
|
"""Integration tests for error handling."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_executor_with_invalid_module(self, test_settings, sample_task_data: dict):
|
|
"""Test task execution with invalid executor module."""
|
|
from src.tasks.executor import TaskExecutor
|
|
|
|
executor = TaskExecutor(test_settings)
|
|
|
|
task = {**sample_task_data, "executor": "nonexistent_executor"}
|
|
|
|
with patch.object(executor, 'get_db_connection') as mock_get_conn:
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_conn = MagicMock()
|
|
mock_cursor = MagicMock()
|
|
|
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=None)
|
|
mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn)
|
|
mock_get_conn.return_value.__exit__ = MagicMock(return_value=None)
|
|
|
|
# Should handle the error gracefully
|
|
await executor.execute_task(task)
|
|
|
|
# Should have recorded the error
|
|
assert mock_cursor.execute.called
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestConfigValidation:
|
|
"""Integration tests for configuration validation."""
|
|
|
|
def test_settings_loads_from_environment(self):
|
|
"""Test that settings load correctly from environment."""
|
|
from src.config import get_settings
|
|
|
|
settings = get_settings()
|
|
|
|
# Should have loaded test environment variables. tests/conftest.py
|
|
# sets POSTGRES_HOST="postgres-shared" (module-level, before `from
|
|
# src.main import app`), annotated "Use real postgres for integration
|
|
# tests" — this assertion checked for "test-postgres", a value
|
|
# nothing in the suite has ever set. Matched to the fixture actually
|
|
# in effect rather than to an unset value.
|
|
assert settings.postgres_host == "postgres-shared"
|
|
assert settings.postgres_db == "test_scheduler"
|
|
assert settings.scheduler_api_key == "test-api-key-12345"
|
|
|
|
def test_settings_provides_database_url(self):
|
|
"""Test that settings provides correct database URL."""
|
|
from src.config import get_settings
|
|
|
|
settings = get_settings()
|
|
db_url = settings.database_url
|
|
|
|
assert "postgresql://" in db_url
|
|
assert "test_scheduler" in db_url
|
|
|
|
def test_settings_provides_redis_url(self):
|
|
"""Test that settings provides correct Redis URL."""
|
|
from src.config import get_settings
|
|
|
|
settings = get_settings()
|
|
redis_url = settings.redis_url
|
|
|
|
assert "redis://" in redis_url
|