feat(F.17): add Coolify projects, databases, services — 67 tools total

Add 3 new Coolify plugin handlers (37 new tools, 67 total):
- projects.py: 8 tools (CRUD projects + environments)
- databases.py: 16 tools (CRUD, lifecycle, 6 DB types, backups)
- services.py: 13 tools (CRUD, lifecycle, env vars)

734 tests passing. Total platform tools: 633.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 15:34:30 +02:00
parent 151ba8e6e6
commit d7e3946e11
13 changed files with 2141 additions and 12 deletions

View File

@@ -75,7 +75,7 @@ class TestCoolifyPlugin:
def test_tool_specifications(self):
"""Test that tool specifications are returned."""
specs = CoolifyPlugin.get_tool_specifications()
assert len(specs) == 30 # 17 apps + 5 deployments + 8 servers
assert len(specs) == 67 # 17 apps + 16 dbs + 5 deploys + 8 projects + 8 servers + 13 svcs
# Check all specs have required fields
for spec in specs:

View File

@@ -0,0 +1,237 @@
"""
Tests for Coolify Databases Handler
Unit tests with mocked HTTP responses.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.coolify.client import CoolifyClient
from plugins.coolify.handlers import databases
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
class TestDatabaseToolSpecs:
"""Tests for database tool specifications."""
def test_database_tool_count(self):
"""Test database tool specification count."""
specs = databases.get_tool_specifications()
assert len(specs) == 16
def test_tool_specs_have_required_fields(self):
"""Test all specs have required fields."""
for spec in databases.get_tool_specifications():
assert "name" in spec
assert "method_name" in spec
assert "description" in spec
assert "schema" in spec
assert "scope" in spec
assert spec["scope"] in ("read", "write", "admin")
def test_tool_names_unique(self):
"""Test that all tool names are unique."""
specs = databases.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_all_db_types_present(self):
"""Test all 6 database creation tools exist."""
specs = databases.get_tool_specifications()
names = [s["name"] for s in specs]
for db_type in ["postgresql", "mysql", "mariadb", "mongodb", "redis", "clickhouse"]:
assert f"create_{db_type}" in names
def test_scope_distribution(self):
"""Test correct scope assignments."""
specs = databases.get_tool_specifications()
by_scope = {}
for s in specs:
by_scope.setdefault(s["scope"], []).append(s["name"])
assert "list_databases" in by_scope["read"]
assert "delete_database" in by_scope["admin"]
assert "start_database" in by_scope["write"]
class TestDatabaseHandlers:
"""Tests for database handler functions."""
@pytest.mark.asyncio
async def test_list_databases(self, client):
"""Test list_databases handler."""
mock_dbs = [
{"uuid": "db-1", "name": "postgres-main", "type": "postgresql", "status": "running"},
{"uuid": "db-2", "name": "redis-cache", "type": "redis", "status": "running"},
]
client.list_databases = AsyncMock(return_value=mock_dbs)
result = await databases.list_databases(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
client.list_databases.assert_called_once()
@pytest.mark.asyncio
async def test_get_database(self, client):
"""Test get_database handler."""
mock_db = {"uuid": "db-1", "name": "postgres-main", "type": "postgresql"}
client.get_database = AsyncMock(return_value=mock_db)
result = await databases.get_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
assert data["database"]["name"] == "postgres-main"
client.get_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_update_database(self, client):
"""Test update_database handler."""
mock_response = {"uuid": "db-1"}
client.update_database = AsyncMock(return_value=mock_response)
result = await databases.update_database(client, uuid="db-1", name="renamed-db")
data = json.loads(result)
assert data["success"] is True
assert "updated" in data["message"].lower()
client.update_database.assert_called_once_with("db-1", {"name": "renamed-db"})
@pytest.mark.asyncio
async def test_delete_database(self, client):
"""Test delete_database handler."""
mock_response = {"message": "Database deleted."}
client.delete_database = AsyncMock(return_value=mock_response)
result = await databases.delete_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
assert "deleted" in data["message"].lower()
@pytest.mark.asyncio
async def test_start_database(self, client):
"""Test start_database handler."""
mock_response = {"message": "Starting."}
client.start_database = AsyncMock(return_value=mock_response)
result = await databases.start_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.start_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_stop_database(self, client):
"""Test stop_database handler."""
mock_response = {"message": "Stopping."}
client.stop_database = AsyncMock(return_value=mock_response)
result = await databases.stop_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.stop_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_restart_database(self, client):
"""Test restart_database handler."""
mock_response = {"message": "Restarting."}
client.restart_database = AsyncMock(return_value=mock_response)
result = await databases.restart_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.restart_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_create_postgresql(self, client):
"""Test create_postgresql handler."""
mock_response = {"uuid": "db-new", "type": "postgresql"}
client.create_database = AsyncMock(return_value=mock_response)
result = await databases.create_postgresql(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
)
data = json.loads(result)
assert data["success"] is True
assert "postgresql" in data["message"].lower()
client.create_database.assert_called_once_with(
"postgresql",
{
"project_uuid": "proj-1",
"server_uuid": "srv-1",
"environment_name": "production",
},
)
@pytest.mark.asyncio
async def test_create_redis(self, client):
"""Test create_redis handler."""
mock_response = {"uuid": "db-new", "type": "redis"}
client.create_database = AsyncMock(return_value=mock_response)
result = await databases.create_redis(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
name="my-cache",
)
data = json.loads(result)
assert data["success"] is True
assert "redis" in data["message"].lower()
@pytest.mark.asyncio
async def test_get_database_backups(self, client):
"""Test get_database_backups handler."""
mock_backups = {"enabled": True, "frequency": "0 0 * * *", "backups": []}
client.get_database_backups = AsyncMock(return_value=mock_backups)
result = await databases.get_database_backups(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.get_database_backups.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_create_database_backup(self, client):
"""Test create_database_backup handler."""
mock_response = {"message": "Backup started."}
client.create_database_backup = AsyncMock(return_value=mock_response)
result = await databases.create_database_backup(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
assert "backup" in data["message"].lower()
@pytest.mark.asyncio
async def test_list_backup_executions(self, client):
"""Test list_backup_executions handler."""
mock_executions = [
{"id": 1, "database_uuid": "db-1", "status": "success"},
{"id": 2, "database_uuid": "db-1", "status": "failed"},
]
client.list_backup_executions = AsyncMock(return_value=mock_executions)
result = await databases.list_backup_executions(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2

View File

@@ -0,0 +1,214 @@
"""
Tests for Coolify Projects & Environments Handler
Unit tests with mocked HTTP responses.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.coolify.client import CoolifyClient
from plugins.coolify.handlers import projects
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
class TestProjectToolSpecs:
"""Tests for project tool specifications."""
def test_project_tool_count(self):
"""Test project tool specification count."""
specs = projects.get_tool_specifications()
assert len(specs) == 8
def test_tool_specs_have_required_fields(self):
"""Test all specs have required fields."""
for spec in projects.get_tool_specifications():
assert "name" in spec
assert "method_name" in spec
assert "description" in spec
assert "schema" in spec
assert "scope" in spec
assert spec["scope"] in ("read", "write", "admin")
def test_tool_names_unique(self):
"""Test that all tool names are unique."""
specs = projects.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_scope_distribution(self):
"""Test correct scope assignments."""
specs = projects.get_tool_specifications()
by_scope = {}
for s in specs:
by_scope.setdefault(s["scope"], []).append(s["name"])
assert "list_projects" in by_scope["read"]
assert "get_project" in by_scope["read"]
assert "create_project" in by_scope["write"]
assert "delete_project" in by_scope["admin"]
class TestProjectHandlers:
"""Tests for project handler functions."""
@pytest.mark.asyncio
async def test_list_projects(self, client):
"""Test list_projects handler."""
mock_projects = [
{"uuid": "proj-1", "name": "mcphub", "description": "MCP Hub project"},
{"uuid": "proj-2", "name": "blog", "description": "Blog project"},
]
client.list_projects = AsyncMock(return_value=mock_projects)
result = await projects.list_projects(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
assert len(data["projects"]) == 2
client.list_projects.assert_called_once()
@pytest.mark.asyncio
async def test_get_project(self, client):
"""Test get_project handler."""
mock_project = {
"uuid": "proj-1",
"name": "mcphub",
"description": "MCP Hub project",
"environments": [{"name": "production"}],
}
client.get_project = AsyncMock(return_value=mock_project)
result = await projects.get_project(client, uuid="proj-1")
data = json.loads(result)
assert data["success"] is True
assert data["project"]["name"] == "mcphub"
client.get_project.assert_called_once_with("proj-1")
@pytest.mark.asyncio
async def test_create_project(self, client):
"""Test create_project handler."""
mock_response = {"uuid": "proj-new", "name": "new-project"}
client.create_project = AsyncMock(return_value=mock_response)
result = await projects.create_project(
client, name="new-project", description="A test project"
)
data = json.loads(result)
assert data["success"] is True
assert "created" in data["message"].lower()
client.create_project.assert_called_once_with(
{"name": "new-project", "description": "A test project"}
)
@pytest.mark.asyncio
async def test_create_project_no_description(self, client):
"""Test create_project without description."""
mock_response = {"uuid": "proj-new"}
client.create_project = AsyncMock(return_value=mock_response)
result = await projects.create_project(client, name="minimal")
data = json.loads(result)
assert data["success"] is True
client.create_project.assert_called_once_with({"name": "minimal"})
@pytest.mark.asyncio
async def test_update_project(self, client):
"""Test update_project handler."""
mock_response = {"uuid": "proj-1"}
client.update_project = AsyncMock(return_value=mock_response)
result = await projects.update_project(client, uuid="proj-1", name="renamed")
data = json.loads(result)
assert data["success"] is True
assert "updated" in data["message"].lower()
client.update_project.assert_called_once_with("proj-1", {"name": "renamed"})
@pytest.mark.asyncio
async def test_delete_project(self, client):
"""Test delete_project handler."""
mock_response = {"message": "Project deleted."}
client.delete_project = AsyncMock(return_value=mock_response)
result = await projects.delete_project(client, uuid="proj-1")
data = json.loads(result)
assert data["success"] is True
assert "deleted" in data["message"].lower()
client.delete_project.assert_called_once_with("proj-1")
class TestEnvironmentHandlers:
"""Tests for environment handler functions."""
@pytest.mark.asyncio
async def test_list_environments(self, client):
"""Test list_environments handler."""
mock_envs = [
{"name": "production", "id": 1},
{"name": "staging", "id": 2},
]
client.list_environments = AsyncMock(return_value=mock_envs)
result = await projects.list_environments(client, project_uuid="proj-1")
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
assert len(data["environments"]) == 2
client.list_environments.assert_called_once_with("proj-1")
@pytest.mark.asyncio
async def test_get_environment(self, client):
"""Test get_environment handler."""
mock_env = {"name": "production", "id": 1, "project_id": 5}
client.get_environment = AsyncMock(return_value=mock_env)
result = await projects.get_environment(
client, project_uuid="proj-1", environment_name="production"
)
data = json.loads(result)
assert data["success"] is True
assert data["environment"]["name"] == "production"
client.get_environment.assert_called_once_with("proj-1", "production")
@pytest.mark.asyncio
async def test_create_environment(self, client):
"""Test create_environment handler."""
mock_response = {"name": "staging", "id": 3}
client.create_environment = AsyncMock(return_value=mock_response)
result = await projects.create_environment(
client, project_uuid="proj-1", name="staging", description="Staging env"
)
data = json.loads(result)
assert data["success"] is True
assert "staging" in data["message"]
client.create_environment.assert_called_once_with(
"proj-1", {"name": "staging", "description": "Staging env"}
)
@pytest.mark.asyncio
async def test_create_environment_no_description(self, client):
"""Test create_environment without description."""
mock_response = {"name": "test"}
client.create_environment = AsyncMock(return_value=mock_response)
result = await projects.create_environment(client, project_uuid="proj-1", name="test")
data = json.loads(result)
assert data["success"] is True
client.create_environment.assert_called_once_with("proj-1", {"name": "test"})

View File

@@ -0,0 +1,240 @@
"""
Tests for Coolify Services Handler
Unit tests with mocked HTTP responses.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.coolify.client import CoolifyClient
from plugins.coolify.handlers import services
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
class TestServiceToolSpecs:
"""Tests for service tool specifications."""
def test_service_tool_count(self):
"""Test service tool specification count."""
specs = services.get_tool_specifications()
assert len(specs) == 13
def test_tool_specs_have_required_fields(self):
"""Test all specs have required fields."""
for spec in services.get_tool_specifications():
assert "name" in spec
assert "method_name" in spec
assert "description" in spec
assert "schema" in spec
assert "scope" in spec
assert spec["scope"] in ("read", "write", "admin")
def test_tool_names_unique(self):
"""Test that all tool names are unique."""
specs = services.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_scope_distribution(self):
"""Test correct scope assignments."""
specs = services.get_tool_specifications()
by_scope = {}
for s in specs:
by_scope.setdefault(s["scope"], []).append(s["name"])
assert "list_services" in by_scope["read"]
assert "list_service_envs" in by_scope["read"]
assert "delete_service" in by_scope["admin"]
assert "start_service" in by_scope["write"]
assert "create_service_env" in by_scope["write"]
class TestServiceHandlers:
"""Tests for service handler functions."""
@pytest.mark.asyncio
async def test_list_services(self, client):
"""Test list_services handler."""
mock_services = [
{"uuid": "svc-1", "name": "plausible", "status": "running"},
{"uuid": "svc-2", "name": "minio", "status": "stopped"},
]
client.list_services = AsyncMock(return_value=mock_services)
result = await services.list_services(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
client.list_services.assert_called_once()
@pytest.mark.asyncio
async def test_get_service(self, client):
"""Test get_service handler."""
mock_service = {"uuid": "svc-1", "name": "plausible", "status": "running"}
client.get_service = AsyncMock(return_value=mock_service)
result = await services.get_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
assert data["service"]["name"] == "plausible"
client.get_service.assert_called_once_with("svc-1")
@pytest.mark.asyncio
async def test_create_service(self, client):
"""Test create_service handler."""
mock_response = {"uuid": "svc-new"}
client.create_service = AsyncMock(return_value=mock_response)
result = await services.create_service(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
type="plausible-analytics",
name="my-plausible",
)
data = json.loads(result)
assert data["success"] is True
assert "created" in data["message"].lower()
@pytest.mark.asyncio
async def test_update_service(self, client):
"""Test update_service handler."""
mock_response = {"uuid": "svc-1"}
client.update_service = AsyncMock(return_value=mock_response)
result = await services.update_service(client, uuid="svc-1", name="renamed")
data = json.loads(result)
assert data["success"] is True
assert "updated" in data["message"].lower()
client.update_service.assert_called_once_with("svc-1", {"name": "renamed"})
@pytest.mark.asyncio
async def test_delete_service(self, client):
"""Test delete_service handler."""
mock_response = {"message": "Service deleted."}
client.delete_service = AsyncMock(return_value=mock_response)
result = await services.delete_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
assert "deleted" in data["message"].lower()
@pytest.mark.asyncio
async def test_start_service(self, client):
"""Test start_service handler."""
mock_response = {"message": "Starting."}
client.start_service = AsyncMock(return_value=mock_response)
result = await services.start_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
client.start_service.assert_called_once_with("svc-1")
@pytest.mark.asyncio
async def test_stop_service(self, client):
"""Test stop_service handler."""
mock_response = {"message": "Stopping."}
client.stop_service = AsyncMock(return_value=mock_response)
result = await services.stop_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
client.stop_service.assert_called_once_with("svc-1")
@pytest.mark.asyncio
async def test_restart_service(self, client):
"""Test restart_service handler."""
mock_response = {"message": "Restarting."}
client.restart_service = AsyncMock(return_value=mock_response)
result = await services.restart_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
client.restart_service.assert_called_once_with("svc-1")
class TestServiceEnvHandlers:
"""Tests for service environment variable handlers."""
@pytest.mark.asyncio
async def test_list_service_envs(self, client):
"""Test list_service_envs handler."""
mock_envs = [
{"key": "DATABASE_URL", "value": "postgres://..."},
{"key": "SECRET", "value": "***"},
]
client.list_service_envs = AsyncMock(return_value=mock_envs)
result = await services.list_service_envs(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
@pytest.mark.asyncio
async def test_create_service_env(self, client):
"""Test create_service_env handler."""
mock_response = {"uuid": "env-123"}
client.create_service_env = AsyncMock(return_value=mock_response)
result = await services.create_service_env(
client, uuid="svc-1", key="NEW_VAR", value="test"
)
data = json.loads(result)
assert data["success"] is True
assert "NEW_VAR" in data["message"]
@pytest.mark.asyncio
async def test_update_service_env(self, client):
"""Test update_service_env handler."""
mock_response = {"uuid": "env-123"}
client.update_service_env = AsyncMock(return_value=mock_response)
result = await services.update_service_env(
client, uuid="svc-1", key="EXISTING_VAR", value="new_val"
)
data = json.loads(result)
assert data["success"] is True
assert "EXISTING_VAR" in data["message"]
@pytest.mark.asyncio
async def test_update_service_envs_bulk(self, client):
"""Test update_service_envs_bulk handler."""
mock_response = {"message": "Updated."}
client.update_service_envs_bulk = AsyncMock(return_value=mock_response)
env_data = [{"key": "A", "value": "1"}, {"key": "B", "value": "2"}]
result = await services.update_service_envs_bulk(client, uuid="svc-1", data=env_data)
data = json.loads(result)
assert data["success"] is True
assert "2" in data["message"]
@pytest.mark.asyncio
async def test_delete_service_env(self, client):
"""Test delete_service_env handler."""
client.delete_service_env = AsyncMock(return_value=None)
result = await services.delete_service_env(client, uuid="svc-1", env_uuid="env-123")
data = json.loads(result)
assert data["success"] is True
client.delete_service_env.assert_called_once_with("svc-1", "env-123")