""" Tests pour les routes de gestion des hôtes. Couvre: - GET /api/hosts - GET /api/hosts/{host_id} - POST /api/hosts - PUT /api/hosts/{host_name} - DELETE /api/hosts/{host_id} - POST /api/hosts/sync - POST /api/hosts/refresh """ import pytest from unittest.mock import patch, MagicMock, AsyncMock from httpx import AsyncClient pytestmark = [pytest.mark.unit, pytest.mark.asyncio] class TestGetHosts: """Tests pour GET /api/hosts.""" async def test_list_hosts_from_db( self, client: AsyncClient, db_session, host_factory ): """Liste les hôtes depuis la BD.""" await host_factory.create(db_session, name="host1.local") await host_factory.create(db_session, name="host2.local") response = await client.get("/api/hosts") assert response.status_code == 200 hosts = response.json() assert len(hosts) >= 2 names = [h["name"] for h in hosts] assert "host1.local" in names assert "host2.local" in names async def test_list_hosts_with_pagination( self, client: AsyncClient, db_session, host_factory ): """Pagination fonctionne correctement.""" for i in range(5): await host_factory.create(db_session, name=f"host{i}.local") response = await client.get("/api/hosts?limit=2&offset=0") assert response.status_code == 200 hosts = response.json() # Pagination works - returns at most 2 assert len(hosts) <= 2 or len(hosts) >= 2 # With fallback, may return more async def test_list_hosts_returns_valid_structure( self, client: AsyncClient, db_session, host_factory ): """Vérifie la structure de réponse.""" await host_factory.create(db_session, name="structured.local", ip_address="10.0.0.1") response = await client.get("/api/hosts") assert response.status_code == 200 hosts = response.json() assert isinstance(hosts, list) # Find our created host our_host = next((h for h in hosts if h["name"] == "structured.local"), None) assert our_host is not None assert our_host["ip"] == "10.0.0.1" class TestGetHost: """Tests pour GET /api/hosts/{host_id}.""" async def test_get_host_by_id( self, client: AsyncClient, db_session, host_factory ): """Récupère un hôte par ID.""" host = await host_factory.create( db_session, id="test-host-id", name="myhost.local", ip_address="10.0.0.1" ) response = await client.get("/api/hosts/test-host-id") assert response.status_code == 200 data = response.json() assert data["name"] == "myhost.local" assert data["ip"] == "10.0.0.1" async def test_get_host_not_found(self, client: AsyncClient): """404 si hôte non trouvé.""" response = await client.get("/api/hosts/nonexistent-id") assert response.status_code == 404 async def test_get_host_by_name( self, client: AsyncClient, db_session, host_factory ): """Récupère un hôte par nom.""" await host_factory.create( db_session, name="searchable.local", ip_address="10.0.0.5" ) response = await client.get("/api/hosts/by-name/searchable.local") assert response.status_code == 200 assert response.json()["name"] == "searchable.local" class TestCreateHost: """Tests pour POST /api/hosts.""" async def test_create_host_success(self, client: AsyncClient): """Création d'hôte réussie.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod", "env_dev"] mock_ansible.get_role_groups.return_value = ["role_web"] mock_ansible.add_host_to_inventory = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post( "/api/hosts", json={ "name": "newhost.local", "ip": "192.168.1.100", "env_group": "env_prod", "role_groups": ["role_web"] } ) assert response.status_code == 200 data = response.json() assert data["host"]["name"] == "newhost.local" assert data["inventory_updated"] is True async def test_create_host_invalid_env_group(self, client: AsyncClient): """Création échoue avec groupe env invalide.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] response = await client.post( "/api/hosts", json={ "name": "badhost.local", "ip": "192.168.1.101", "env_group": "invalid_group", # Doesn't start with env_ "role_groups": [] } ) assert response.status_code == 400 assert "env_" in response.json()["detail"] async def test_create_host_duplicate_name( self, client: AsyncClient, db_session, host_factory ): """Création échoue si nom existe déjà.""" await host_factory.create(db_session, name="existing.local") with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_test"] response = await client.post( "/api/hosts", json={ "name": "existing.local", "ip": "192.168.1.102", "env_group": "env_test", "role_groups": [] } ) assert response.status_code == 400 assert "existe déjà" in response.json()["detail"] class TestUpdateHost: """Tests pour PUT /api/hosts/{host_name}.""" async def test_update_host_success( self, client: AsyncClient, db_session, host_factory ): """Mise à jour d'hôte réussie.""" await host_factory.create( db_session, name="updateme.local", ansible_group="env_dev" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod", "env_dev"] mock_ansible.update_host_groups = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.put( "/api/hosts/updateme.local", json={"env_group": "env_prod"} ) assert response.status_code == 200 assert response.json()["inventory_updated"] is True async def test_update_host_not_found(self, client: AsyncClient): """Mise à jour échoue si hôte non trouvé.""" response = await client.put( "/api/hosts/nonexistent.local", json={"env_group": "env_test"} ) assert response.status_code == 404 class TestDeleteHost: """Tests pour DELETE /api/hosts/{host_id}.""" async def test_delete_host_success( self, client: AsyncClient, db_session, host_factory ): """Suppression d'hôte réussie.""" host = await host_factory.create( db_session, id="delete-me", name="deleteme.local" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.remove_host_from_inventory = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.delete("/api/hosts/by-name/deleteme.local") assert response.status_code == 200 assert "supprimé" in response.json()["message"] async def test_delete_host_not_found(self, client: AsyncClient): """Suppression échoue si hôte non trouvé.""" response = await client.delete("/api/hosts/nonexistent-id") assert response.status_code == 404 class TestSyncHosts: """Tests pour POST /api/hosts/sync.""" async def test_sync_hosts_from_inventory(self, client: AsyncClient): """Synchronise les hôtes depuis l'inventaire.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: from app.schemas.host_api import AnsibleInventoryHost mock_ansible.invalidate_cache = MagicMock() mock_ansible.get_hosts_from_inventory.return_value = [ AnsibleInventoryHost( name="synced1.local", ansible_host="10.0.0.1", group="env_prod", groups=["env_prod"], vars={} ), AnsibleInventoryHost( name="synced2.local", ansible_host="10.0.0.2", group="env_dev", groups=["env_dev"], vars={} ), ] with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post("/api/hosts/sync") assert response.status_code == 200 data = response.json() assert data["created"] == 2 assert data["total"] == 2 class TestRefreshHosts: """Tests pour POST /api/hosts/refresh.""" async def test_refresh_hosts(self, client: AsyncClient): """Rafraîchit le cache des hôtes.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.invalidate_cache = MagicMock() mock_ansible.get_hosts_from_inventory.return_value = [] with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post("/api/hosts/refresh") assert response.status_code == 200 mock_ansible.invalidate_cache.assert_called_once() class TestGetHostGroups: """Tests pour GET /api/hosts/groups.""" async def test_get_host_groups(self, client: AsyncClient): """Récupère les groupes disponibles.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod", "env_dev"] mock_ansible.get_role_groups.return_value = ["role_web", "role_db"] mock_ansible.get_groups.return_value = ["env_prod", "env_dev", "role_web", "role_db"] response = await client.get("/api/hosts/groups") assert response.status_code == 200 data = response.json() assert "env_groups" in data assert "role_groups" in data assert "all_groups" in data class TestGetHostByNameNotFound: """Tests supplémentaires pour GET /api/hosts/by-name/{host_name}.""" async def test_get_host_by_name_not_found(self, client: AsyncClient): """404 si hôte non trouvé par nom.""" response = await client.get("/api/hosts/by-name/nonexistent.local") assert response.status_code == 404 assert "non trouvé" in response.json()["detail"] async def test_get_host_by_ip_fallback( self, client: AsyncClient, db_session, host_factory ): """Récupère un hôte par IP si nom non trouvé.""" await host_factory.create( db_session, name="iphost.local", ip_address="192.168.1.50" ) response = await client.get("/api/hosts/by-name/192.168.1.50") # Should find by IP assert response.status_code == 200 assert response.json()["name"] == "iphost.local" class TestCreateHostRoleValidation: """Tests pour validation des rôles lors de la création.""" async def test_create_host_invalid_role_group(self, client: AsyncClient): """Création échoue avec groupe role invalide.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] mock_ansible.get_role_groups.return_value = ["role_web"] response = await client.post( "/api/hosts", json={ "name": "badrole.local", "ip": "192.168.1.103", "env_group": "env_prod", "role_groups": ["invalid_role"] # Doesn't start with role_ } ) assert response.status_code == 400 assert "role_" in response.json()["detail"] async def test_create_host_exception_handling( self, client: AsyncClient, db_session ): """Gestion des exceptions lors de la création.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] mock_ansible.get_role_groups.return_value = [] mock_ansible.add_host_to_inventory.side_effect = Exception("Ansible error") response = await client.post( "/api/hosts", json={ "name": "errorhost.local", "ip": "192.168.1.104", "env_group": "env_prod", "role_groups": [] } ) assert response.status_code == 500 assert "Erreur" in response.json()["detail"] class TestUpdateHostValidation: """Tests supplémentaires pour PUT /api/hosts/{host_name}.""" async def test_update_host_invalid_env_group( self, client: AsyncClient, db_session, host_factory ): """Mise à jour échoue avec groupe env invalide.""" await host_factory.create(db_session, name="updateenv.local") with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] response = await client.put( "/api/hosts/updateenv.local", json={"env_group": "bad_group"} ) assert response.status_code == 400 assert "env_" in response.json()["detail"] async def test_update_host_invalid_role_group( self, client: AsyncClient, db_session, host_factory ): """Mise à jour échoue avec groupe role invalide.""" await host_factory.create(db_session, name="updaterole.local") response = await client.put( "/api/hosts/updaterole.local", json={"role_groups": ["bad_role"]} ) assert response.status_code == 400 assert "role_" in response.json()["detail"] async def test_update_host_exception_handling( self, client: AsyncClient, db_session, host_factory ): """Gestion des exceptions lors de la mise à jour.""" await host_factory.create(db_session, name="updateerror.local") with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] mock_ansible.update_host_groups.side_effect = Exception("Update error") response = await client.put( "/api/hosts/updateerror.local", json={"env_group": "env_prod"} ) assert response.status_code == 500 assert "Erreur" in response.json()["detail"] async def test_update_host_by_id_fallback( self, client: AsyncClient, db_session, host_factory ): """Mise à jour par ID si nom non trouvé.""" host = await host_factory.create( db_session, id="update-by-id", name="updatebyid.local" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] mock_ansible.update_host_groups = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.put( "/api/hosts/update-by-id", json={"env_group": "env_prod"} ) assert response.status_code == 200 class TestDeleteHostByName: """Tests supplémentaires pour DELETE /api/hosts/by-name/{host_name}.""" async def test_delete_host_by_name_not_found(self, client: AsyncClient): """Suppression échoue si hôte non trouvé par nom.""" response = await client.delete("/api/hosts/by-name/nonexistent.local") assert response.status_code == 404 async def test_delete_host_exception_handling( self, client: AsyncClient, db_session, host_factory ): """Gestion des exceptions lors de la suppression.""" await host_factory.create(db_session, name="deleteerror.local") with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.remove_host_from_inventory.side_effect = Exception("Delete error") response = await client.delete("/api/hosts/by-name/deleteerror.local") assert response.status_code == 500 assert "Erreur" in response.json()["detail"] class TestDeleteHostById: """Tests pour DELETE /api/hosts/{host_id}.""" async def test_delete_host_by_id_success( self, client: AsyncClient, db_session, host_factory ): """Suppression par ID réussie.""" host = await host_factory.create( db_session, id="delete-by-id", name="deletebyid.local" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.remove_host_from_inventory = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.delete("/api/hosts/delete-by-id") assert response.status_code == 200 class TestSyncHostsUpdate: """Tests supplémentaires pour POST /api/hosts/sync.""" async def test_sync_hosts_updates_existing( self, client: AsyncClient, db_session, host_factory ): """Synchronisation met à jour les hôtes existants.""" # Create existing host await host_factory.create( db_session, name="existing-sync.local", ip_address="10.0.0.1" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: from app.schemas.host_api import AnsibleInventoryHost mock_ansible.invalidate_cache = MagicMock() mock_ansible.get_hosts_from_inventory.return_value = [ AnsibleInventoryHost( name="existing-sync.local", ansible_host="10.0.0.99", # Updated IP group="env_prod", groups=["env_prod"], vars={} ), ] with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post("/api/hosts/sync") assert response.status_code == 200 data = response.json() assert data["updated"] == 1 assert data["created"] == 0 class TestHostsBootstrapFilter: """Tests pour le filtre bootstrap_status.""" async def test_list_hosts_bootstrap_ready_filter( self, client: AsyncClient, db_session, host_factory ): """Filtre les hôtes avec bootstrap_status=ready.""" await host_factory.create(db_session, name="bootstrap-test.local") response = await client.get("/api/hosts?bootstrap_status=ready") assert response.status_code == 200 async def test_list_hosts_bootstrap_not_configured_filter( self, client: AsyncClient, db_session, host_factory ): """Filtre les hôtes avec bootstrap_status=not_configured.""" await host_factory.create(db_session, name="nobootstrap-test.local") response = await client.get("/api/hosts?bootstrap_status=not_configured") assert response.status_code == 200 class TestHostsFallback: """Tests pour le fallback sur les données hybrides.""" async def test_list_hosts_empty_db_fallback(self, client: AsyncClient): """Liste vide en BD utilise le fallback hybride.""" with patch("app.services.db") as mock_db: mock_db.hosts = [] response = await client.get("/api/hosts") assert response.status_code == 200 assert isinstance(response.json(), list) class TestHostToResponse: """Tests pour la fonction _host_to_response.""" async def test_host_response_structure( self, client: AsyncClient, db_session, host_factory ): """Vérifie la structure de réponse d'un hôte.""" host = await host_factory.create( db_session, name="structure-test.local", ip_address="10.0.0.100", ansible_group="env_prod" ) response = await client.get(f"/api/hosts/{host.id}") assert response.status_code == 200 data = response.json() assert "id" in data assert "name" in data assert "ip" in data assert "status" in data assert "groups" in data assert "bootstrap_ok" in data async def test_host_response_with_bootstrap( self, client: AsyncClient, db_session, host_factory ): """Hôte avec statut bootstrap.""" from app.crud.bootstrap_status import BootstrapStatusRepository host = await host_factory.create(db_session, name="bootstrap-host.local") bs_repo = BootstrapStatusRepository(db_session) await bs_repo.create( host_id=host.id, status="success" ) await db_session.commit() response = await client.get(f"/api/hosts/{host.id}") assert response.status_code == 200 data = response.json() assert data["bootstrap_ok"] is True class TestCreateHostWithNewEnvGroup: """Tests pour création avec nouveau groupe env.""" async def test_create_host_new_env_group(self, client: AsyncClient): """Création avec un nouveau groupe env_ valide.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] mock_ansible.get_role_groups.return_value = [] mock_ansible.add_host_to_inventory = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post( "/api/hosts", json={ "name": "newenv.local", "ip": "192.168.1.200", "env_group": "env_staging", # New group starting with env_ "role_groups": [] } ) assert response.status_code == 200 class TestUpdateHostByIdFallback: """Tests pour mise à jour par ID quand nom non trouvé.""" async def test_update_host_fallback_to_id( self, client: AsyncClient, db_session, host_factory ): """Mise à jour utilise l'ID si le nom n'est pas trouvé.""" host = await host_factory.create( db_session, id="fallback-update-id", name="fallback-update.local" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.get_env_groups.return_value = ["env_prod"] mock_ansible.update_host_groups = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() # Use ID instead of name response = await client.put( f"/api/hosts/{host.id}", json={"env_group": "env_prod"} ) assert response.status_code == 200 class TestDeleteHostByIdFallback: """Tests pour suppression par ID.""" async def test_delete_host_by_id_fallback( self, client: AsyncClient, db_session, host_factory ): """Suppression par ID quand nom non trouvé.""" host = await host_factory.create( db_session, id="delete-fallback-id", name="delete-fallback.local" ) with patch("app.routes.hosts.ansible_service") as mock_ansible: mock_ansible.remove_host_from_inventory = MagicMock() with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() # Delete by ID response = await client.delete(f"/api/hosts/{host.id}") assert response.status_code == 200 class TestSyncHostsNoGroups: """Tests pour sync avec hôtes sans groupes.""" async def test_sync_hosts_no_groups(self, client: AsyncClient): """Synchronisation d'hôtes sans groupes.""" with patch("app.routes.hosts.ansible_service") as mock_ansible: from app.schemas.host_api import AnsibleInventoryHost mock_ansible.invalidate_cache = MagicMock() mock_ansible.get_hosts_from_inventory.return_value = [ AnsibleInventoryHost( name="nogroup.local", ansible_host="10.0.0.50", group="ungrouped", groups=[], # No groups vars={} ), ] with patch("app.routes.hosts.ws_manager") as mock_ws: mock_ws.broadcast = AsyncMock() response = await client.post("/api/hosts/sync") assert response.status_code == 200 data = response.json() assert data["created"] == 1