_should_run_now has no definition in src/ in any commit in this repo's history — checked with `git log --all -S` across the whole tree, not just the current worktree. Twelve assertion sites across six tests called it, so these have never passed and never protected anything. The behaviour they describe is real: cron-wildcard matching of minute, hour, day_of_month, month and day_of_week. It lives inside the WHERE clause of get_tasks_for_minute, not as a Python predicate, so there was nothing to rename them onto. KNOWN GAP, stated rather than left implied: that matching is now covered by no test at all. Testing it means either asserting against the SQL and params a mocked cursor receives, or extracting the predicate out of the query — the second changes what decides, every minute, which scheduled work runs, and is not a refactor to do casually. Deleting was chosen over rewriting because a test that has never run is not coverage, and leaving it in place claimed some. Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
6.3 KiB
Python
171 lines
6.3 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.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
|