""" Tests pour les routes de gestion des tâches. Couvre: - Liste des tâches - Tâches en cours - Logs de tâches - Exécution de tâches """ import pytest from unittest.mock import patch, AsyncMock, MagicMock from httpx import AsyncClient from datetime import datetime pytestmark = pytest.mark.unit class TestGetTasks: """Tests pour GET /api/tasks.""" async def test_list_tasks_empty(self, client: AsyncClient, db_session): """Liste vide quand pas de tâches.""" response = await client.get("/api/tasks") assert response.status_code == 200 assert isinstance(response.json(), list) async def test_list_tasks_with_data( self, client: AsyncClient, db_session, task_factory ): """Liste les tâches depuis la BD.""" await task_factory.create(db_session, action="health-check", target="all") await task_factory.create(db_session, action="backup", target="server1") response = await client.get("/api/tasks") assert response.status_code == 200 tasks = response.json() assert len(tasks) >= 2 async def test_list_tasks_with_pagination( self, client: AsyncClient, db_session, task_factory ): """Pagination fonctionne.""" for i in range(5): await task_factory.create(db_session, action=f"task-{i}", target="all") response = await client.get("/api/tasks?limit=2&offset=0") assert response.status_code == 200 tasks = response.json() assert isinstance(tasks, list) async def test_list_tasks_structure( self, client: AsyncClient, db_session, task_factory ): """Vérifie la structure de réponse.""" await task_factory.create( db_session, action="test-action", target="test-host", status="completed" ) response = await client.get("/api/tasks") assert response.status_code == 200 tasks = response.json() assert len(tasks) >= 1 task = tasks[0] assert "id" in task assert "name" in task assert "host" in task assert "status" in task class TestGetRunningTasks: """Tests pour GET /api/tasks/running.""" async def test_running_tasks_empty(self, client: AsyncClient, db_session): """Aucune tâche en cours.""" response = await client.get("/api/tasks/running") assert response.status_code == 200 data = response.json() assert "tasks" in data assert "count" in data assert data["count"] == 0 async def test_running_tasks_with_running( self, client: AsyncClient, db_session, task_factory ): """Tâches en cours retournées.""" await task_factory.create(db_session, action="running-task", status="running") await task_factory.create(db_session, action="completed-task", status="completed") response = await client.get("/api/tasks/running") assert response.status_code == 200 data = response.json() assert "tasks" in data # Only running tasks should be returned for task in data["tasks"]: assert task["status"] in ("running", "pending") class TestTaskLogs: """Tests pour GET /api/tasks/logs.""" async def test_get_task_logs(self, client: AsyncClient): """Récupère les logs de tâches.""" response = await client.get("/api/tasks/logs") assert response.status_code == 200 async def test_get_task_logs_with_filters(self, client: AsyncClient): """Logs avec filtres.""" response = await client.get("/api/tasks/logs?status=success&limit=10") assert response.status_code == 200 class TestExecuteTask: """Tests pour POST /api/tasks.""" @pytest.mark.asyncio async def test_execute_task_endpoint_exists(self, client: AsyncClient): """Endpoint d'exécution existe.""" # Just verify the endpoint responds (auth is overridden in tests) response = await client.post( "/api/tasks", json={"action": "health-check", "target": "all"} ) # Should get a response (may be 200, 400, or 422 depending on validation) assert response.status_code in [200, 400, 422] @pytest.mark.asyncio async def test_execute_task_missing_action(self, client: AsyncClient): """Erreur si action manquante.""" response = await client.post( "/api/tasks", json={"target": "all"} ) assert response.status_code == 422 @pytest.mark.asyncio async def test_execute_task_with_host(self, client: AsyncClient): """Exécution sur un hôte spécifique.""" with patch("app.routes.tasks.ansible_service") as mock_ansible, \ patch("app.routes.tasks.ws_manager") as mock_ws, \ patch("app.routes.tasks.db") as mock_db: mock_ansible.host_exists.return_value = True mock_ansible.execute_playbook = AsyncMock(return_value=MagicMock( return_code=0, stdout="Success", stderr="" )) mock_ws.broadcast = AsyncMock() mock_db.add_task = MagicMock() mock_db.update_task = MagicMock() mock_db.get_next_id = MagicMock(return_value=1) response = await client.post( "/api/tasks", json={"action": "health-check", "host": "test-host"} ) # Should accept the request assert response.status_code in [200, 400, 422] class TestHostHealthStatusUpdate: """Tests du lien health-check -> MAJ status/last_seen host en base.""" async def test_health_check_updates_host_in_db( self, async_engine, db_session, host_factory, mock_ws_manager, mock_notification_service, ): # Créer un host en base, avec un nom qui correspondra au target host = await host_factory.create( db_session, name="test-host-1.local", ip_address="192.168.1.10", status="unknown", reachable=False, last_seen=None, ) # Exécuter le runner directement (le endpoint /api/tasks lance un asyncio.create_task) from app.routes import tasks as tasks_routes # Forcer le runner à utiliser le même engine in-memory que le reste des tests from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession test_session_maker = async_sessionmaker( async_engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, ) with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={ "success": True, "return_code": 0, "stdout": "ok", "stderr": "", })), patch.object(tasks_routes, "async_session_maker", test_session_maker): await tasks_routes._execute_task_playbook( task_id="task-health-1", task_name="Vérification de santé", playbook="health-check.yml", target=host.name, extra_vars=None, check_mode=False, ) # Recharger depuis la DB et vérifier MAJ from app.crud.host import HostRepository async with test_session_maker() as verify_session: repo = HostRepository(verify_session) updated = await repo.get(host.id) assert updated is not None assert updated.status == "online" assert updated.reachable is True assert updated.last_seen is not None assert isinstance(updated.last_seen, datetime) async def test_health_check_all_updates_each_host_from_recap( self, async_engine, db_session, host_factory, mock_ws_manager, mock_notification_service, ): host1 = await host_factory.create( db_session, name="host1", ip_address="192.168.1.11", status="unknown", reachable=False, last_seen=None, ) host2 = await host_factory.create( db_session, name="host2", ip_address="192.168.1.12", status="unknown", reachable=False, last_seen=None, ) from app.routes import tasks as tasks_routes from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession test_session_maker = async_sessionmaker( async_engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, ) stdout = ( "PLAY RECAP\n" "host1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n" "host2 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0\n" ) with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={ "success": False, "return_code": 2, "stdout": stdout, "stderr": "", })), patch.object(tasks_routes, "async_session_maker", test_session_maker): await tasks_routes._execute_task_playbook( task_id="task-health-all-1", task_name="Vérification de santé", playbook="health-check.yml", target="all", extra_vars=None, check_mode=False, ) from app.crud.host import HostRepository async with test_session_maker() as verify_session: repo = HostRepository(verify_session) updated1 = await repo.get(host1.id) updated2 = await repo.get(host2.id) assert updated1 is not None assert updated1.status == "online" assert updated1.reachable is True assert updated1.last_seen is not None assert updated2 is not None assert updated2.status == "offline" assert updated2.reachable is False assert updated2.last_seen is not None class TestAnsibleExecuteHealthCheckStatusUpdate: async def test_ansible_execute_health_check_updates_hosts_for_role_group( self, client: AsyncClient, db_session, host_factory, ): host1 = await host_factory.create( db_session, name="orangepi.pc.home", ip_address="10.10.0.11", status="unknown", reachable=False, last_seen=None, ) host2 = await host_factory.create( db_session, name="raspi.4gb.home", ip_address="10.10.0.12", status="unknown", reachable=False, last_seen=None, ) from app.schemas.host_api import AnsibleInventoryHost inv_hosts = [ AnsibleInventoryHost(name="orangepi", ansible_host=host1.ip_address, group="env_homelab", groups=["role_sbc", "env_homelab"], vars={}), AnsibleInventoryHost(name="raspi", ansible_host=host2.ip_address, group="env_homelab", groups=["role_sbc", "env_homelab"], vars={}), ] stdout = ( "PLAY RECAP\n" "orangepi : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n" "raspi : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0\n" ) with patch("app.routes.ansible.ansible_service.execute_playbook", new=AsyncMock(return_value={ "success": False, "return_code": 2, "stdout": stdout, "stderr": "", "execution_time": 1.0, })), patch("app.routes.ansible.ansible_service.get_hosts_from_inventory", new=MagicMock(return_value=inv_hosts)): resp = await client.post( "/api/ansible/execute", json={ "playbook": "health-check.yml", "target": "role_sbc", "check_mode": False, "verbose": True, }, ) assert resp.status_code == 200 # Verify host statuses are updated via DB session from app.crud.host import HostRepository repo = HostRepository(db_session) updated1 = await repo.get(host1.id) updated2 = await repo.get(host2.id) assert updated1 is not None and updated1.status == "online" and updated1.reachable is True and updated1.last_seen is not None assert updated2 is not None and updated2.status == "offline" and updated2.reachable is False and updated2.last_seen is not None async def test_health_check_group_updates_each_host_from_recap( self, async_engine, db_session, host_factory, mock_ws_manager, mock_notification_service, ): host1 = await host_factory.create( db_session, name="group-host-1", ip_address="10.0.0.1", status="unknown", reachable=False, last_seen=None, ) host2 = await host_factory.create( db_session, name="group-host-2", ip_address="10.0.0.2", status="unknown", reachable=False, last_seen=None, ) from app.routes import tasks as tasks_routes from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession test_session_maker = async_sessionmaker( async_engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, ) stdout = ( "PLAY RECAP\n" "group-host-1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n" "group-host-2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n" ) with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={ "success": True, "return_code": 0, "stdout": stdout, "stderr": "", })), patch.object(tasks_routes, "async_session_maker", test_session_maker): await tasks_routes._execute_task_playbook( task_id="task-health-group-1", task_name="Vérification de santé", playbook="health-check.yml", target="env_test", extra_vars=None, check_mode=False, ) from app.crud.host import HostRepository async with test_session_maker() as verify_session: repo = HostRepository(verify_session) updated1 = await repo.get(host1.id) updated2 = await repo.get(host2.id) assert updated1 is not None and updated1.status == "online" and updated1.reachable is True and updated1.last_seen is not None assert updated2 is not None and updated2.status == "online" and updated2.reachable is True and updated2.last_seen is not None async def test_health_check_group_updates_hosts_when_recap_uses_inventory_alias( self, async_engine, db_session, host_factory, mock_ws_manager, mock_notification_service, ): # DB stores hosts by their FQDN/IP, but Ansible recap may print the inventory alias. host1 = await host_factory.create( db_session, name="sbc-01.local", ip_address="10.42.0.11", status="unknown", reachable=False, last_seen=None, ) host2 = await host_factory.create( db_session, name="sbc-02.local", ip_address="10.42.0.12", status="unknown", reachable=False, last_seen=None, ) from app.routes import tasks as tasks_routes from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession from app.schemas.host_api import AnsibleInventoryHost test_session_maker = async_sessionmaker( async_engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, ) # Inventory aliases for the role group "role_sbc" inv_hosts = [ AnsibleInventoryHost(name="sbc-01", ansible_host=host1.ip_address, group="env_test", groups=["role_sbc", "env_test"], vars={}), AnsibleInventoryHost(name="sbc-02", ansible_host=host2.ip_address, group="env_test", groups=["role_sbc", "env_test"], vars={}), ] stdout = ( "PLAY RECAP\n" "sbc-01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n" "sbc-02 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0\n" ) with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={ "success": False, "return_code": 2, "stdout": stdout, "stderr": "", })), patch.object(tasks_routes.ansible_service, "get_hosts_from_inventory", new=MagicMock(return_value=inv_hosts)), patch.object( tasks_routes, "async_session_maker", test_session_maker, ): await tasks_routes._execute_task_playbook( task_id="task-health-role-alias-1", task_name="Vérification de santé", playbook="health-check.yml", target="role_sbc", extra_vars=None, check_mode=False, ) from app.crud.host import HostRepository async with test_session_maker() as verify_session: repo = HostRepository(verify_session) updated1 = await repo.get(host1.id) updated2 = await repo.get(host2.id) assert updated1 is not None assert updated1.status == "online" assert updated1.reachable is True assert updated1.last_seen is not None assert updated2 is not None assert updated2.status == "offline" assert updated2.reachable is False assert updated2.last_seen is not None class TestGetTaskById: """Tests pour GET /api/tasks/{task_id}.""" @pytest.mark.asyncio async def test_get_task_not_found(self, client: AsyncClient): """404 si tâche non trouvée.""" with patch("app.routes.tasks.db") as mock_db: mock_db.get_task.return_value = None response = await client.get("/api/tasks/nonexistent") assert response.status_code == 404 class TestCancelTask: """Tests pour POST /api/tasks/{task_id}/cancel.""" @pytest.mark.asyncio async def test_cancel_task_not_found(self, client: AsyncClient): """404 si tâche non trouvée.""" with patch("app.routes.tasks.db") as mock_db: mock_db.get_task.return_value = None response = await client.post("/api/tasks/nonexistent/cancel") assert response.status_code == 404 @pytest.mark.asyncio async def test_cancel_task_not_running( self, client: AsyncClient, db_session, task_factory ): """Erreur si tâche pas en cours.""" task = await task_factory.create( db_session, id="completed-task", action="test", status="completed" ) response = await client.post(f"/api/tasks/{task.id}/cancel") assert response.status_code == 400 assert "n'est pas en cours" in response.json()["detail"] @pytest.mark.asyncio async def test_cancel_task_success( self, client: AsyncClient, db_session, task_factory ): """Annulation réussie d'une tâche en cours.""" task = await task_factory.create( db_session, id="running-cancel-task", action="test", status="running" ) with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post(f"/api/tasks/{task.id}/cancel") assert response.status_code == 200 assert response.json()["success"] is True class TestGetTaskById: """Tests pour GET /api/tasks/{task_id}.""" @pytest.mark.asyncio async def test_get_task_by_id_success( self, client: AsyncClient, db_session, task_factory ): """Récupère une tâche par ID.""" task = await task_factory.create( db_session, id="get-task-id", action="test-action", target="test-host", status="completed" ) response = await client.get(f"/api/tasks/{task.id}") assert response.status_code == 200 data = response.json() assert data["id"] == task.id assert data["name"] == "test-action" class TestDeleteTask: """Tests pour DELETE /api/tasks/{task_id}.""" @pytest.mark.asyncio async def test_delete_task_success( self, client: AsyncClient, db_session, task_factory ): """Suppression d'une tâche réussie.""" task = await task_factory.create( db_session, id="delete-task-id", action="test", status="completed" ) with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.delete(f"/api/tasks/{task.id}") assert response.status_code == 200 assert "supprimée" in response.json()["message"] @pytest.mark.asyncio async def test_delete_task_not_found(self, client: AsyncClient): """404 si tâche non trouvée.""" response = await client.delete("/api/tasks/nonexistent-id") assert response.status_code == 404 class TestTaskLogsDates: """Tests pour GET /api/tasks/logs/dates.""" @pytest.mark.asyncio async def test_get_task_logs_dates(self, client: AsyncClient): """Récupère les dates disponibles.""" response = await client.get("/api/tasks/logs/dates") assert response.status_code == 200 class TestTaskLogsStats: """Tests pour GET /api/tasks/logs/stats.""" @pytest.mark.asyncio async def test_get_task_logs_stats(self, client: AsyncClient): """Récupère les statistiques des logs.""" response = await client.get("/api/tasks/logs/stats") assert response.status_code == 200 class TestTaskLogContent: """Tests pour GET /api/tasks/logs/{log_id}.""" @pytest.mark.asyncio async def test_get_task_log_content_not_found(self, client: AsyncClient): """404 si log non trouvé.""" response = await client.get("/api/tasks/logs/nonexistent-log-id") assert response.status_code == 404 class TestDeleteTaskLog: """Tests pour DELETE /api/tasks/logs/{log_id}.""" @pytest.mark.asyncio async def test_delete_task_log_not_found(self, client: AsyncClient): """404 si log non trouvé.""" response = await client.delete("/api/tasks/logs/nonexistent-log-id") assert response.status_code == 404 class TestCreateTask: """Tests supplémentaires pour POST /api/tasks.""" @pytest.mark.asyncio async def test_create_task_with_group(self, client: AsyncClient, db_session): """Création de tâche avec groupe.""" with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post( "/api/tasks", json={ "action": "health-check", "group": "env_prod" } ) assert response.status_code == 200 data = response.json() assert data["host"] == "env_prod" @pytest.mark.asyncio async def test_create_task_with_extra_vars(self, client: AsyncClient, db_session): """Création de tâche avec variables extra.""" with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post( "/api/tasks", json={ "action": "health-check", "host": "test-host", "extra_vars": {"key": "value"} } ) assert response.status_code == 200 class TestTaskLogOperations: """Tests pour les opérations sur les logs de tâches.""" @pytest.mark.asyncio async def test_get_task_logs_with_all_filters(self, client: AsyncClient): """Récupère les logs avec tous les filtres.""" response = await client.get( "/api/tasks/logs?status=success&year=2025&month=01&day=15&target=all&limit=10&offset=0" ) assert response.status_code == 200 data = response.json() assert "logs" in data assert "filters" in data assert "pagination" in data @pytest.mark.asyncio async def test_get_task_logs_with_hour_filter(self, client: AsyncClient): """Récupère les logs avec filtre d'heure.""" response = await client.get( "/api/tasks/logs?hour_start=08&hour_end=18" ) assert response.status_code == 200 @pytest.mark.asyncio async def test_get_task_logs_with_category(self, client: AsyncClient): """Récupère les logs avec filtre catégorie.""" response = await client.get("/api/tasks/logs?category=backup") assert response.status_code == 200 @pytest.mark.asyncio async def test_get_task_logs_with_source_type(self, client: AsyncClient): """Récupère les logs avec filtre source_type.""" response = await client.get("/api/tasks/logs?source_type=manual") assert response.status_code == 200 class TestCancelTaskWithHandle: """Tests pour l'annulation de tâches avec handles.""" @pytest.mark.asyncio async def test_cancel_task_with_asyncio_task( self, client: AsyncClient, db_session, task_factory ): """Annulation avec asyncio task.""" task = await task_factory.create( db_session, id="cancel-asyncio-task", action="test", status="running" ) # Mock the running_task_handles with patch("app.routes.tasks.running_task_handles", { "cancel-asyncio-task": { "cancelled": False, "asyncio_task": MagicMock(done=MagicMock(return_value=False), cancel=MagicMock()) } }): with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post("/api/tasks/cancel-asyncio-task/cancel") assert response.status_code == 200 @pytest.mark.asyncio async def test_cancel_task_with_process( self, client: AsyncClient, db_session, task_factory ): """Annulation avec process.""" task = await task_factory.create( db_session, id="cancel-process-task", action="test", status="running" ) mock_process = MagicMock() mock_process.returncode = None mock_process.terminate = MagicMock() mock_process.kill = MagicMock() with patch("app.routes.tasks.running_task_handles", { "cancel-process-task": { "cancelled": False, "process": mock_process } }): with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() with patch("asyncio.sleep", new_callable=AsyncMock): response = await client.post("/api/tasks/cancel-process-task/cancel") assert response.status_code == 200 class TestTaskResponseStructure: """Tests pour la structure des réponses de tâches.""" @pytest.mark.asyncio async def test_task_response_has_all_fields( self, client: AsyncClient, db_session, task_factory ): """Vérifie que la réponse contient tous les champs.""" task = await task_factory.create( db_session, id="structure-task", action="test-action", target="test-host", status="completed" ) response = await client.get("/api/tasks/structure-task") assert response.status_code == 200 data = response.json() assert "id" in data assert "name" in data assert "host" in data assert "status" in data assert "progress" in data assert "start_time" in data assert "end_time" in data @pytest.mark.asyncio async def test_running_task_progress( self, client: AsyncClient, db_session, task_factory ): """Vérifie le progress pour une tâche running.""" task = await task_factory.create( db_session, id="running-progress-task", action="test", status="running" ) response = await client.get("/api/tasks/running-progress-task") assert response.status_code == 200 data = response.json() assert data["progress"] == 50 # Running = 50% @pytest.mark.asyncio async def test_completed_task_progress( self, client: AsyncClient, db_session, task_factory ): """Vérifie le progress pour une tâche completed.""" task = await task_factory.create( db_session, id="completed-progress-task", action="test", status="completed" ) response = await client.get("/api/tasks/completed-progress-task") assert response.status_code == 200 data = response.json() assert data["progress"] == 100 # Completed = 100% class TestCreateTaskWithPlaybook: """Tests pour création de tâches avec playbook.""" @pytest.mark.asyncio async def test_create_task_triggers_playbook(self, client: AsyncClient, db_session): """Création de tâche déclenche l'exécution du playbook.""" with patch("app.routes.tasks.ws_manager") as mock_ws, \ patch("app.routes.tasks.asyncio.create_task") as mock_create_task: mock_ws.broadcast = AsyncMock() def _create_task_and_close(coro): coro.close() return MagicMock() mock_create_task.side_effect = _create_task_and_close response = await client.post( "/api/tasks", json={ "action": "health-check", "host": "all" } ) assert response.status_code == 200 data = response.json() assert data["status"] == "running" @pytest.mark.asyncio async def test_create_task_default_target(self, client: AsyncClient, db_session): """Création de tâche avec target par défaut.""" with patch("app.routes.tasks.ws_manager") as mock_ws, \ patch("app.routes.tasks.asyncio.create_task") as mock_create_task: mock_ws.broadcast = AsyncMock() def _create_task_and_close(coro): coro.close() return MagicMock() mock_create_task.side_effect = _create_task_and_close response = await client.post( "/api/tasks", json={ "action": "health-check" # No host or group specified } ) assert response.status_code == 200 data = response.json() assert data["host"] == "all" # Default target class TestDeleteTaskVerify: """Tests pour vérifier la suppression de tâches.""" @pytest.mark.asyncio async def test_delete_task_verify_removed( self, client: AsyncClient, db_session, task_factory ): """Vérifie que la tâche est bien supprimée.""" task = await task_factory.create( db_session, id="verify-delete-task", action="test", status="completed" ) with patch("app.routes.tasks.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() # Delete delete_response = await client.delete("/api/tasks/verify-delete-task") assert delete_response.status_code == 200 # Verify deleted get_response = await client.get("/api/tasks/verify-delete-task") assert get_response.status_code == 404