homelab_automation/tests/backend/test_routes_tasks.py
Bruno Charest 27eed55c9b
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Update test coverage timestamps and fix coroutine cleanup in task creation tests by properly closing coroutines in mocked asyncio.create_task calls
2025-12-15 08:31:12 -05:00

620 lines
20 KiB
Python

"""
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
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 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