#region TestValidationTasksApi [C:3] [TYPE Module] [SEMANTICS test, validation, api, task, v2] # @BRIEF Tests for v2 validation task API routes — multimodal enforcement, schema validation, CRUD. # @RELATION BINDS_TO -> [ValidationTasksRoutes] # @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient] # @TEST_CONTRACT [ValidationTaskCreate] -> [ValidationTaskResponse | HTTP 422] # @TEST_EDGE: non_multimodal_provider -> 422 when screenshot_enabled=true + non-multimodal # @TEST_EDGE: missing_field -> 422 when required fields omitted # @TEST_EDGE: external_fail -> 404 when task not found # @INVARIANT screenshot_enabled=true requires multimodal provider — enforced at create and update # Set required env vars before ANY app imports — crash-early guard for AuthConfig() import os os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_validation_tasks") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_validation_tasks") os.environ.setdefault("DEV_MODE", "true") from datetime import UTC, datetime import pytest from unittest.mock import MagicMock from fastapi.testclient import TestClient from src.api.routes.validation_tasks import _get_task_service from src.app import app from src.dependencies import ( get_config_manager, get_current_user, get_scheduler_service, get_task_manager, ) from src.models.auth import User from src.schemas.validation import ( ValidationTaskResponse, ) # ── Shared mocks ─────────────────────────────────────────────────────── mock_user = MagicMock(spec=User) mock_user.username = "testuser" mock_user.roles = [] admin_role = MagicMock() admin_role.name = "Admin" admin_role.permissions = [] mock_user.roles.append(admin_role) def _make_now(): return datetime.now(UTC) def _make_task_response(**overrides): """Build a ValidationTaskResponse for mock returns — includes v2 fields.""" return ValidationTaskResponse( id=overrides.get("id", "task-v2-1"), name=overrides.get("name", "V2 Test Task"), environment_id=overrides.get("environment_id", "env-1"), is_active=overrides.get("is_active", True), dashboard_ids=overrides.get("dashboard_ids", ["dash-1"]), provider_id=overrides.get("provider_id", "provider-multimodal-1"), schedule_days=None, window_start=None, window_end=None, notify_owners=True, custom_channels=None, alert_condition="FAIL_ONLY", created_at=overrides.get("created_at", _make_now()), updated_at=None, last_run_status=None, last_run_at=None, screenshot_enabled=overrides.get("screenshot_enabled", True), logs_enabled=overrides.get("logs_enabled", True), execute_chart_data=overrides.get("execute_chart_data", False), llm_batch_size=overrides.get("llm_batch_size", 1), policy_dashboard_concurrency_limit=overrides.get("policy_dashboard_concurrency_limit", 3), prompt_template=overrides.get("prompt_template"), sources=overrides.get("sources", []), ) @pytest.fixture(autouse=True) def mock_all_deps(): """Override FastAPI dependencies with MagicMock stubs for validation_tasks routes. Permission checking (has_permission) is NOT overridden — mock_user has Admin role which passes all permission gates implicitly. Resets mock_user.roles before each test. """ mock_user.roles = [admin_role] mock_task_service = MagicMock() mock_task_manager = MagicMock() mock_scheduler = MagicMock() mock_config = MagicMock() app.dependency_overrides[_get_task_service] = lambda: mock_task_service app.dependency_overrides[get_task_manager] = lambda: mock_task_manager app.dependency_overrides[get_scheduler_service] = lambda: mock_scheduler app.dependency_overrides[get_config_manager] = lambda: mock_config app.dependency_overrides[get_current_user] = lambda: mock_user yield { "task_service": mock_task_service, "task_manager": mock_task_manager, "scheduler": mock_scheduler, "config": mock_config, } app.dependency_overrides.clear() client = TestClient(app) #region test_create_task_rejects_non_multimodal_provider [C:2] [TYPE Function] # @BRIEF T061: screenshot_enabled=true with non-multimodal provider → 400/422. # @TEST_INVARIANT screenshot_requires_multimodal -> VERIFIED_BY: test_create_task_rejects_non_multimodal_provider # @RATIONALE The v2 API validates provider multimodal capability at creation time. # Path A (screenshot) requires multimodal; Path B (text-only) does not. # @NOTE Current behavior returns 422 (ValueError → HTTPException 422). # Contract expectation per FR-058 is 400. Update assertion when route is aligned. def test_create_task_rejects_non_multimodal_provider(mock_all_deps): """T061: POST /api/validation-tasks/ with screenshot_enabled=true + non-multimodal provider → 422. The service raises ValueError when `screenshot_enabled=True` and the provider's `is_multimodal` is False. The route handler converts this to HTTP 422 UNPROCESSABLE_ENTITY. """ mock_all_deps["task_service"].create_task.side_effect = ValueError( "LLM provider 'GPT-4-mini' is not multimodal. " "Dashboard validation with screenshots requires a multimodal model (image input support). " "Set screenshot_enabled=False to use a text-only provider." ) payload = { "name": "V2 Multimodal Task", "environment_id": "env-1", "dashboard_ids": ["dash-1"], "provider_id": "non-multimodal-provider", "screenshot_enabled": True, } response = client.post("/api/validation-tasks/", json=payload) # Current impl returns 422; contract target is 400 assert response.status_code == 422 data = response.json() assert "not multimodal" in data["detail"] assert "screenshot_enabled=False" in data["detail"] #endregion test_create_task_rejects_non_multimodal_provider #region test_create_task_with_text_only_provider_succeeds [C:2] [TYPE Function] # @BRIEF T061 variant: screenshot_enabled=false with non-multimodal provider succeeds (Path B). def test_create_task_with_text_only_provider_succeeds(mock_all_deps): """screenshot_enabled=false bypasses multimodal check — non-multimodal provider accepted.""" mock_all_deps["task_service"].create_task.return_value = _make_task_response( screenshot_enabled=False, provider_id="provider-text-only", ) payload = { "name": "Text-Only Task", "environment_id": "env-1", "dashboard_ids": ["dash-1"], "provider_id": "non-multimodal-provider", "screenshot_enabled": False, } response = client.post("/api/validation-tasks/", json=payload) assert response.status_code == 201 data = response.json() assert data["screenshot_enabled"] is False assert data["provider_id"] == "provider-text-only" #endregion test_create_task_with_text_only_provider_succeeds #region test_create_task_with_v2_fields [C:2] [TYPE Function] # @BRIEF All v2 fields are accepted on creation and reflected in response. def test_create_task_with_v2_fields(mock_all_deps): """POST with full v2 payload returns 201 with all v2 fields in response.""" mock_all_deps["task_service"].create_task.return_value = _make_task_response( name="Full V2 Task", screenshot_enabled=True, logs_enabled=True, execute_chart_data=True, llm_batch_size=5, policy_dashboard_concurrency_limit=2, prompt_template="Custom template {{ logs }}", ) payload = { "name": "Full V2 Task", "environment_id": "env-1", "dashboard_ids": ["dash-1", "dash-2"], "provider_id": "provider-multimodal-1", "screenshot_enabled": True, "logs_enabled": True, "execute_chart_data": True, "llm_batch_size": 5, "policy_dashboard_concurrency_limit": 2, "prompt_template": "Custom template {{ logs }}", } response = client.post("/api/validation-tasks/", json=payload) assert response.status_code == 201 data = response.json() assert data["screenshot_enabled"] is True assert data["logs_enabled"] is True assert data["execute_chart_data"] is True assert data["llm_batch_size"] == 5 assert data["policy_dashboard_concurrency_limit"] == 2 assert data["prompt_template"] == "Custom template {{ logs }}" #endregion test_create_task_with_v2_fields #region test_delete_task_with_scheduler_notify [C:2] [TYPE Function] # @BRIEF DELETE notifies scheduler to remove validation job. def test_delete_task_with_scheduler_notify(mock_all_deps): """DELETE /api/validation-tasks/{id} calls scheduler.remove_validation_job.""" mock_all_deps["task_service"].delete_task.return_value = None response = client.delete("/api/validation-tasks/task-v2-1") assert response.status_code == 204 mock_all_deps["scheduler"].remove_validation_job.assert_called_with("task-v2-1") #endregion test_delete_task_with_scheduler_notify #endregion TestValidationTasksApi