UNPKG

@masonator/coolify-mcp

Version:

MCP server implementation for Coolify

1,001 lines 126 kB
import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { CoolifyClient } from '../lib/coolify-client.js'; // Helper to create mock response function mockResponse(data, ok = true, status = 200) { return { ok, status, statusText: ok ? 'OK' : 'Error', text: async () => JSON.stringify(data), }; } const mockFetch = jest.fn(); describe('CoolifyClient', () => { let client; const mockServers = [ { id: 1, uuid: 'test-uuid', name: 'test-server', ip: '192.168.1.1', user: 'root', port: 22, status: 'running', is_reachable: true, created_at: '2024-01-01', updated_at: '2024-01-01', }, ]; const mockServerInfo = { id: 1, uuid: 'test-uuid', name: 'test-server', ip: '192.168.1.1', user: 'root', port: 22, created_at: '2024-01-01', updated_at: '2024-01-01', }; const mockServerResources = [ { id: 1, uuid: 'resource-uuid', name: 'test-app', type: 'application', status: 'running', created_at: '2024-01-01', updated_at: '2024-01-01', }, ]; const mockService = { id: 1, uuid: 'test-uuid', name: 'test-service', type: 'code-server', status: 'running', domains: ['test.example.com'], created_at: '2024-01-01', updated_at: '2024-01-01', }; const mockApplication = { id: 1, uuid: 'app-uuid', name: 'test-app', status: 'running', fqdn: 'https://app.example.com', git_repository: 'https://github.com/user/repo', git_branch: 'main', created_at: '2024-01-01', updated_at: '2024-01-01', }; const mockDatabase = { id: 1, uuid: 'db-uuid', name: 'test-db', type: 'postgresql', status: 'running', is_public: false, created_at: '2024-01-01', updated_at: '2024-01-01', }; const mockDeployment = { id: 1, uuid: 'dep-uuid', deployment_uuid: 'dep-123', application_name: 'test-app', status: 'finished', force_rebuild: false, is_webhook: false, is_api: true, restart_only: false, created_at: '2024-01-01', updated_at: '2024-01-01', }; const mockProject = { id: 1, uuid: 'proj-uuid', name: 'test-project', description: 'A test project', created_at: '2024-01-01', updated_at: '2024-01-01', }; const errorResponse = { message: 'Resource not found', }; beforeEach(() => { mockFetch.mockClear(); global.fetch = mockFetch; client = new CoolifyClient({ baseUrl: 'http://localhost:3000', accessToken: 'test-api-key', }); }); describe('constructor', () => { it('should throw error if baseUrl is missing', () => { expect(() => new CoolifyClient({ baseUrl: '', accessToken: 'test' })).toThrow('Coolify base URL is required'); }); it('should throw error if accessToken is missing', () => { expect(() => new CoolifyClient({ baseUrl: 'http://localhost', accessToken: '' })).toThrow('Coolify access token is required'); }); it('should strip trailing slash from baseUrl', () => { const c = new CoolifyClient({ baseUrl: 'http://localhost:3000/', accessToken: 'test', }); mockFetch.mockResolvedValueOnce(mockResponse({ version: '1.0.0' })); c.getVersion(); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/version', expect.any(Object)); }); }); describe('listServers', () => { it('should return a list of servers', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockServers)); const servers = await client.listServers(); expect(servers).toEqual(mockServers); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers', expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json', Authorization: 'Bearer test-api-key', }), })); }); it('should handle errors', async () => { mockFetch.mockResolvedValueOnce(mockResponse(errorResponse, false, 404)); await expect(client.listServers()).rejects.toThrow('Resource not found'); }); it('should support pagination options', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockServers)); await client.listServers({ page: 2, per_page: 10 }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers?page=2&per_page=10', expect.any(Object)); }); it('should return summary when requested', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockServers)); const result = await client.listServers({ summary: true }); expect(result).toEqual([ { uuid: 'test-uuid', name: 'test-server', ip: '192.168.1.1', status: 'running', is_reachable: true, }, ]); }); }); describe('getServer', () => { it('should get server info', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockServerInfo)); const result = await client.getServer('test-uuid'); expect(result).toEqual(mockServerInfo); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers/test-uuid', expect.any(Object)); }); it('should handle errors', async () => { mockFetch.mockResolvedValueOnce(mockResponse(errorResponse, false, 404)); await expect(client.getServer('test-uuid')).rejects.toThrow('Resource not found'); }); }); describe('getServerResources', () => { it('should get server resources', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockServerResources)); const result = await client.getServerResources('test-uuid'); expect(result).toEqual(mockServerResources); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers/test-uuid/resources', expect.any(Object)); }); it('should handle errors', async () => { mockFetch.mockResolvedValueOnce(mockResponse(errorResponse, false, 404)); await expect(client.getServerResources('test-uuid')).rejects.toThrow('Resource not found'); }); }); describe('listServices', () => { it('should list services', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockService])); const result = await client.listServices(); expect(result).toEqual([mockService]); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services', expect.any(Object)); }); }); describe('getService', () => { it('should get service info', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockService)); const result = await client.getService('test-uuid'); expect(result).toEqual(mockService); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid', expect.any(Object)); }); }); describe('createService', () => { it('should create a service', async () => { const responseData = { uuid: 'test-uuid', domains: ['test.com'], }; mockFetch.mockResolvedValueOnce(mockResponse(responseData)); const createData = { name: 'test-service', type: 'code-server', project_uuid: 'project-uuid', environment_uuid: 'env-uuid', server_uuid: 'server-uuid', }; const result = await client.createService(createData); expect(result).toEqual(responseData); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services', expect.objectContaining({ method: 'POST', body: JSON.stringify(createData), })); }); it('should pass through already base64-encoded docker_compose_raw', async () => { const responseData = { uuid: 'compose-uuid', domains: ['custom.example.com'], }; mockFetch.mockResolvedValueOnce(mockResponse(responseData)); const base64Value = 'dmVyc2lvbjogIjMiCnNlcnZpY2VzOgogIGFwcDoKICAgIGltYWdlOiBuZ2lueA=='; const createData = { name: 'custom-compose-service', project_uuid: 'project-uuid', environment_uuid: 'env-uuid', server_uuid: 'server-uuid', docker_compose_raw: base64Value, }; const result = await client.createService(createData); expect(result).toEqual(responseData); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services', expect.objectContaining({ method: 'POST', body: JSON.stringify(createData), })); }); it('should auto base64-encode raw YAML docker_compose_raw', async () => { const responseData = { uuid: 'compose-uuid', domains: ['test.com'] }; mockFetch.mockResolvedValueOnce(mockResponse(responseData)); const rawYaml = 'services:\n test:\n image: nginx'; const createData = { name: 'raw-compose', project_uuid: 'project-uuid', environment_uuid: 'env-uuid', server_uuid: 'server-uuid', docker_compose_raw: rawYaml, }; await client.createService(createData); const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body); // Should be base64-encoded in the request expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64')); // Should NOT be the raw YAML expect(callBody.docker_compose_raw).not.toBe(rawYaml); }); }); describe('deleteService', () => { it('should delete a service', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Service deleted' })); const result = await client.deleteService('test-uuid'); expect(result).toEqual({ message: 'Service deleted' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid', expect.objectContaining({ method: 'DELETE', })); }); it('should delete a service with options', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Service deleted' })); await client.deleteService('test-uuid', { deleteVolumes: true, dockerCleanup: true, }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid?delete_volumes=true&docker_cleanup=true', expect.objectContaining({ method: 'DELETE', })); }); it('should delete a service with all options', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Service deleted' })); await client.deleteService('test-uuid', { deleteConfigurations: true, deleteVolumes: true, dockerCleanup: true, deleteConnectedNetworks: true, }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid?delete_configurations=true&delete_volumes=true&docker_cleanup=true&delete_connected_networks=true', expect.objectContaining({ method: 'DELETE', })); }); }); describe('applications', () => { it('should list applications', async () => { const mockApps = [{ id: 1, uuid: 'app-uuid', name: 'test-app' }]; mockFetch.mockResolvedValueOnce(mockResponse(mockApps)); const result = await client.listApplications(); expect(result).toEqual(mockApps); }); it('should start an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Started', deployment_uuid: 'dep-uuid' })); const result = await client.startApplication('app-uuid', { force: true, }); expect(result).toEqual({ message: 'Started', deployment_uuid: 'dep-uuid', }); }); it('should stop an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Stopped' })); const result = await client.stopApplication('app-uuid'); expect(result).toEqual({ message: 'Stopped' }); }); }); describe('databases', () => { it('should list databases', async () => { const mockDbs = [{ id: 1, uuid: 'db-uuid', name: 'test-db' }]; mockFetch.mockResolvedValueOnce(mockResponse(mockDbs)); const result = await client.listDatabases(); expect(result).toEqual(mockDbs); }); it('should start a database', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Started' })); const result = await client.startDatabase('db-uuid'); expect(result).toEqual({ message: 'Started' }); }); }); describe('teams', () => { it('should list teams', async () => { const mockTeams = [{ id: 1, name: 'test-team', personal_team: false }]; mockFetch.mockResolvedValueOnce(mockResponse(mockTeams)); const result = await client.listTeams(); expect(result).toEqual(mockTeams); }); it('should get current team', async () => { const mockTeam = { id: 1, name: 'my-team', personal_team: true }; mockFetch.mockResolvedValueOnce(mockResponse(mockTeam)); const result = await client.getCurrentTeam(); expect(result).toEqual(mockTeam); }); }); describe('deployments', () => { it('should list deployments', async () => { const mockDeps = [ { id: 1, uuid: 'dep-uuid', deployment_uuid: 'dep-123', status: 'finished', }, ]; mockFetch.mockResolvedValueOnce(mockResponse(mockDeps)); const result = await client.listDeployments(); expect(result).toEqual(mockDeps); }); it('should deploy by tag', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' })); const result = await client.deployByTagOrUuid('my-tag', true); expect(result).toEqual({ message: 'Deployed' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?tag=my-tag&force=true', expect.any(Object)); }); it('should deploy by Coolify UUID (24 char alphanumeric)', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' })); // Coolify-style UUID: 24 lowercase alphanumeric chars await client.deployByTagOrUuid('xs0sgs4gog044s4k4c88kgsc', false); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?uuid=xs0sgs4gog044s4k4c88kgsc&force=false', expect.any(Object)); }); it('should deploy by standard UUID format', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' })); // Standard UUID format with hyphens await client.deployByTagOrUuid('a1b2c3d4-e5f6-7890-abcd-ef1234567890', true); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?uuid=a1b2c3d4-e5f6-7890-abcd-ef1234567890&force=true', expect.any(Object)); }); }); describe('private keys', () => { it('should list private keys', async () => { const mockKeys = [{ id: 1, uuid: 'key-uuid', name: 'my-key' }]; mockFetch.mockResolvedValueOnce(mockResponse(mockKeys)); const result = await client.listPrivateKeys(); expect(result).toEqual(mockKeys); }); it('should create a private key', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-key-uuid' })); const result = await client.createPrivateKey({ name: 'new-key', private_key: 'ssh-rsa AAAA...', }); expect(result).toEqual({ uuid: 'new-key-uuid' }); }); }); describe('github apps', () => { const mockGitHubApp = { id: 1, uuid: 'gh-app-uuid', name: 'my-github-app', organization: null, api_url: 'https://api.github.com', html_url: 'https://github.com', custom_user: 'git', custom_port: 22, app_id: 12345, installation_id: 67890, client_id: 'client-123', is_system_wide: false, is_public: false, private_key_id: 1, team_id: 0, type: 'github', administration: null, contents: null, metadata: null, pull_requests: null, created_at: '2024-01-01', updated_at: '2024-01-01', }; it('should list github apps', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockGitHubApp])); const result = await client.listGitHubApps(); expect(result).toEqual([mockGitHubApp]); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/github-apps', expect.any(Object)); }); it('should list github apps with summary', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockGitHubApp])); const result = await client.listGitHubApps({ summary: true }); expect(result).toEqual([ { id: 1, uuid: 'gh-app-uuid', name: 'my-github-app', organization: null, is_public: false, app_id: 12345, }, ]); }); it('should create a github app', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockGitHubApp)); const result = await client.createGitHubApp({ name: 'my-github-app', api_url: 'https://api.github.com', html_url: 'https://github.com', app_id: 12345, installation_id: 67890, client_id: 'client-123', client_secret: 'secret-456', private_key_uuid: 'key-uuid', }); expect(result).toEqual(mockGitHubApp); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/github-apps', expect.objectContaining({ method: 'POST' })); }); it('should update a github app', async () => { const updateResponse = { message: 'GitHub app updated successfully', data: mockGitHubApp }; mockFetch.mockResolvedValueOnce(mockResponse(updateResponse)); const result = await client.updateGitHubApp(1, { name: 'updated-app' }); expect(result).toEqual(updateResponse); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/github-apps/1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'updated-app' }), })); }); it('should delete a github app', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'GitHub app deleted successfully' })); const result = await client.deleteGitHubApp(1); expect(result).toEqual({ message: 'GitHub app deleted successfully' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/github-apps/1', expect.objectContaining({ method: 'DELETE' })); }); }); describe('error handling', () => { it('should handle network errors', async () => { mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); await expect(client.listServers()).rejects.toThrow('Failed to connect to Coolify server'); }); it('should handle empty responses', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 204, text: async () => '', }); const result = await client.deleteServer('test-uuid'); expect(result).toEqual({}); }); it('should handle API errors without message', async () => { mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); await expect(client.listServers()).rejects.toThrow('HTTP 500: Error'); }); it('should include validation errors in error message', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Validation failed.', errors: { name: ['The name field is required.'], email: ['The email must be valid.', 'The email is already taken.'], }, }, false, 422)); await expect(client.listServers()).rejects.toThrow('Validation failed. - name: The name field is required.; email: The email must be valid., The email is already taken.'); }); it('should handle validation errors with string messages', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Validation failed.', errors: { docker_compose_raw: 'The docker compose raw field is required.', }, }, false, 422)); await expect(client.listServers()).rejects.toThrow('Validation failed. - docker_compose_raw: The docker compose raw field is required.'); }); it('should handle validation errors with mixed array and string messages', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Validation failed.', errors: { name: ['The name field is required.'], docker_compose_raw: 'The docker compose raw field is required.', }, }, false, 422)); await expect(client.listServers()).rejects.toThrow('Validation failed. - name: The name field is required.; docker_compose_raw: The docker compose raw field is required.'); }); }); // ========================================================================= // Server endpoints - additional coverage // ========================================================================= describe('server operations', () => { it('should create a server', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-server-uuid' })); const result = await client.createServer({ name: 'new-server', ip: '10.0.0.1', private_key_uuid: 'key-uuid', }); expect(result).toEqual({ uuid: 'new-server-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers', expect.objectContaining({ method: 'POST', })); }); it('should update a server', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ ...mockServerInfo, name: 'updated-server' })); const result = await client.updateServer('test-uuid', { name: 'updated-server' }); expect(result.name).toBe('updated-server'); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers/test-uuid', expect.objectContaining({ method: 'PATCH' })); }); it('should get server domains', async () => { const mockDomains = [{ domain: 'example.com', ip: '1.2.3.4' }]; mockFetch.mockResolvedValueOnce(mockResponse(mockDomains)); const result = await client.getServerDomains('test-uuid'); expect(result).toEqual(mockDomains); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers/test-uuid/domains', expect.any(Object)); }); it('should validate a server', async () => { const mockValidation = { valid: true }; mockFetch.mockResolvedValueOnce(mockResponse(mockValidation)); const result = await client.validateServer('test-uuid'); expect(result).toEqual(mockValidation); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/servers/test-uuid/validate', expect.any(Object)); }); }); // ========================================================================= // Project endpoints // ========================================================================= describe('projects', () => { it('should list projects', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockProject])); const result = await client.listProjects(); expect(result).toEqual([mockProject]); }); it('should list projects with pagination', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockProject])); await client.listProjects({ page: 1, per_page: 5 }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects?page=1&per_page=5', expect.any(Object)); }); it('should list projects with summary', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockProject])); const result = await client.listProjects({ summary: true }); expect(result).toEqual([ { uuid: 'proj-uuid', name: 'test-project', description: 'A test project', }, ]); }); it('should get a project', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockProject)); const result = await client.getProject('proj-uuid'); expect(result).toEqual(mockProject); }); it('should create a project', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-proj-uuid' })); const result = await client.createProject({ name: 'new-project' }); expect(result).toEqual({ uuid: 'new-proj-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects', expect.objectContaining({ method: 'POST' })); }); it('should update a project', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ ...mockProject, name: 'updated-project' })); const result = await client.updateProject('proj-uuid', { name: 'updated-project' }); expect(result.name).toBe('updated-project'); }); it('should delete a project', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deleted' })); const result = await client.deleteProject('proj-uuid'); expect(result).toEqual({ message: 'Deleted' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/proj-uuid', expect.objectContaining({ method: 'DELETE' })); }); }); // ========================================================================= // Environment endpoints // ========================================================================= describe('environments', () => { const mockEnvironment = { id: 1, uuid: 'env-uuid', name: 'production', project_uuid: 'proj-uuid', created_at: '2024-01-01', updated_at: '2024-01-01', }; it('should list project environments', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockEnvironment])); const result = await client.listProjectEnvironments('proj-uuid'); expect(result).toEqual([mockEnvironment]); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/proj-uuid/environments', expect.any(Object)); }); it('should get a project environment', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockEnvironment)); const result = await client.getProjectEnvironment('proj-uuid', 'production'); expect(result).toEqual(mockEnvironment); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/proj-uuid/production', expect.any(Object)); }); it('should get project environment with missing database types', async () => { // Use environment_id to match (what real API uses) const mockDbSummaries = [ { uuid: 'pg-uuid', name: 'pg-db', type: 'postgresql', status: 'running', is_public: false, environment_id: 1, }, { uuid: 'dragonfly-uuid', name: 'dragonfly-cache', type: 'standalone-dragonfly', status: 'running', is_public: false, environment_id: 1, }, { uuid: 'other-env-db', name: 'other-db', type: 'standalone-keydb', status: 'running', is_public: false, environment_id: 999, // different env }, ]; mockFetch .mockResolvedValueOnce(mockResponse(mockEnvironment)) .mockResolvedValueOnce(mockResponse(mockDbSummaries)); const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production'); expect(result.uuid).toBe('env-uuid'); expect(result.dragonflys).toHaveLength(1); expect(result.dragonflys[0].uuid).toBe('dragonfly-uuid'); expect(result.keydbs).toBeUndefined(); // other-env-db is in different env }); it('should match databases by environment_uuid fallback', async () => { const mockDbSummaries = [ { uuid: 'keydb-uuid', name: 'keydb-cache', type: 'standalone-keydb', status: 'running', is_public: false, environment_uuid: 'env-uuid', // matching by uuid }, ]; mockFetch .mockResolvedValueOnce(mockResponse(mockEnvironment)) .mockResolvedValueOnce(mockResponse(mockDbSummaries)); const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production'); expect(result.keydbs).toHaveLength(1); expect(result.keydbs[0].uuid).toBe('keydb-uuid'); }); it('should match databases by environment_name fallback', async () => { const mockDbSummaries = [ { uuid: 'clickhouse-uuid', name: 'clickhouse-analytics', type: 'standalone-clickhouse', status: 'running', is_public: false, environment_name: 'production', // matching by name }, ]; mockFetch .mockResolvedValueOnce(mockResponse(mockEnvironment)) .mockResolvedValueOnce(mockResponse(mockDbSummaries)); const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production'); expect(result.clickhouses).toHaveLength(1); expect(result.clickhouses[0].uuid).toBe('clickhouse-uuid'); }); it('should not add empty arrays when no missing DB types exist', async () => { const mockDbSummaries = [ { uuid: 'pg-uuid', name: 'pg-db', type: 'postgresql', // not a "missing" type status: 'running', is_public: false, environment_id: 1, }, ]; mockFetch .mockResolvedValueOnce(mockResponse(mockEnvironment)) .mockResolvedValueOnce(mockResponse(mockDbSummaries)); const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production'); expect(result.dragonflys).toBeUndefined(); expect(result.keydbs).toBeUndefined(); expect(result.clickhouses).toBeUndefined(); }); it('should create a project environment', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' })); const result = await client.createProjectEnvironment('proj-uuid', { name: 'staging' }); expect(result).toEqual({ uuid: 'new-env-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/proj-uuid/environments', expect.objectContaining({ method: 'POST' })); }); it('should delete a project environment', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deleted' })); const result = await client.deleteProjectEnvironment('project-uuid', 'env-uuid'); expect(result).toEqual({ message: 'Deleted' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/project-uuid/environments/env-uuid', expect.objectContaining({ method: 'DELETE' })); }); }); // ========================================================================= // Application endpoints - extended coverage // ========================================================================= describe('applications extended', () => { it('should list applications with pagination', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockApplication])); await client.listApplications({ page: 1, per_page: 20 }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications?page=1&per_page=20', expect.any(Object)); }); it('should list applications with summary', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockApplication])); const result = await client.listApplications({ summary: true }); expect(result).toEqual([ { uuid: 'app-uuid', name: 'test-app', status: 'running', fqdn: 'https://app.example.com', git_repository: 'https://github.com/user/repo', git_branch: 'main', }, ]); }); it('should get an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockApplication)); const result = await client.getApplication('app-uuid'); expect(result).toEqual(mockApplication); }); it('should create application from public repo', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const result = await client.createApplicationPublic({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', git_repository: 'https://github.com/user/repo', git_branch: 'main', build_pack: 'nixpacks', ports_exposes: '3000', }); expect(result).toEqual({ uuid: 'new-app-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/public', expect.objectContaining({ method: 'POST' })); }); it('should create application from private GH repo', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const result = await client.createApplicationPrivateGH({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', github_app_uuid: 'gh-app-uuid', git_repository: 'user/repo', git_branch: 'main', build_pack: 'nixpacks', ports_exposes: '3000', }); expect(result).toEqual({ uuid: 'new-app-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/private-github-app', expect.objectContaining({ method: 'POST' })); }); it('should create application from private key repo', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const result = await client.createApplicationPrivateKey({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', private_key_uuid: 'key-uuid', git_repository: 'git@github.com:user/repo.git', git_branch: 'main', build_pack: 'nixpacks', ports_exposes: '22', }); expect(result).toEqual({ uuid: 'new-app-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/private-deploy-key', expect.objectContaining({ method: 'POST' })); }); it('should create application from dockerfile', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const result = await client.createApplicationDockerfile({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', dockerfile: 'FROM node:18', }); expect(result).toEqual({ uuid: 'new-app-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/dockerfile', expect.objectContaining({ method: 'POST' })); }); it('should create application from docker image', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const result = await client.createApplicationDockerImage({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', docker_registry_image_name: 'nginx:latest', ports_exposes: '80', }); expect(result).toEqual({ uuid: 'new-app-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/dockerimage', expect.objectContaining({ method: 'POST' })); }); it('should create application from docker compose', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const result = await client.createApplicationDockerCompose({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', docker_compose_raw: 'version: "3"', }); expect(result).toEqual({ uuid: 'new-app-uuid' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/dockercompose', expect.objectContaining({ method: 'POST' })); }); it('should auto base64-encode docker_compose_raw in createApplicationDockerCompose', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); const rawYaml = 'services:\n app:\n image: nginx'; await client.createApplicationDockerCompose({ project_uuid: 'proj-uuid', server_uuid: 'server-uuid', docker_compose_raw: rawYaml, }); const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body); expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64')); }); /** * Issue #76 - Client Layer Behavior Test * * This test documents that the client passes through whatever data it receives. * The client itself is NOT buggy - it correctly sends all fields to the API. * * The FIX for #76 is in mcp-server.ts which now strips 'action' before * calling client methods. This test ensures the client behavior remains * predictable (pass-through) so the MCP server layer must handle filtering. */ it('client passes through action field when included in create data (documents #76 fix location)', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' })); // This simulates what mcp-server.ts does: passing full args with action const argsFromMcpTool = { action: 'create_public', // This should NOT be sent to the API project_uuid: 'proj-uuid', server_uuid: 'server-uuid', git_repository: 'https://github.com/user/repo', git_branch: 'main', build_pack: 'nixpacks', ports_exposes: '3000', }; await client.createApplicationPublic(argsFromMcpTool); // This assertion proves the bug: 'action' IS included in the request body expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/public', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"action":"create_public"'), })); }); it('should update an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ ...mockApplication, name: 'updated-app' })); const result = await client.updateApplication('app-uuid', { name: 'updated-app' }); expect(result.name).toBe('updated-app'); }); it('should update an application and verify request body', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ ...mockApplication, name: 'updated-app' })); await client.updateApplication('app-uuid', { name: 'updated-app', description: 'new desc' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'updated-app', description: 'new desc' }), })); }); it('should auto base64-encode docker_compose_raw in updateApplication', async () => { mockFetch.mockResolvedValueOnce(mockResponse(mockApplication)); const rawYaml = 'services:\n app:\n image: nginx'; await client.updateApplication('app-uuid', { docker_compose_raw: rawYaml }); const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body); expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64')); }); /** * Issue #76 - Client Layer Behavior Test * * This test documents that the client passes through whatever data it receives. * The client itself is NOT buggy - it correctly sends all fields to the API. * * The FIX for #76 is in mcp-server.ts which now strips 'action' before * calling client methods. This test ensures the client behavior remains * predictable (pass-through) so the MCP server layer must handle filtering. */ it('client passes through action field when included in update data (documents #76 fix location)', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ ...mockApplication, name: 'updated-app' })); // This simulates what mcp-server.ts does: passing the full args object including 'action' const argsFromMcpTool = { action: 'update', // This should NOT be sent to the API uuid: 'app-uuid', // This is extracted separately name: 'updated-app', description: 'new desc', }; // The client passes whatever it receives to the API await client.updateApplication('app-uuid', argsFromMcpTool); // This assertion proves the bug: 'action' IS included in the request body // The Coolify API will reject this with "action: This field is not allowed" expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid', expect.objectContaining({ method: 'PATCH', body: expect.stringContaining('"action":"update"'), })); }); it('should delete an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deleted' })); const result = await client.deleteApplication('app-uuid'); expect(result).toEqual({ message: 'Deleted' }); }); it('should delete an application with options', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deleted' })); await client.deleteApplication('app-uuid', { deleteVolumes: true, dockerCleanup: true, deleteConfigurations: true, deleteConnectedNetworks: true, }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid?delete_configurations=true&delete_volumes=true&docker_cleanup=true&delete_connected_networks=true', expect.objectContaining({ method: 'DELETE' })); }); it('should get application logs', async () => { mockFetch.mockResolvedValueOnce(mockResponse('log line 1\nlog line 2')); const result = await client.getApplicationLogs('app-uuid', 50); expect(result).toBe('log line 1\nlog line 2'); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/logs?lines=50', expect.any(Object)); }); it('should restart an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' })); const result = await client.restartApplication('app-uuid'); expect(result).toEqual({ message: 'Restarted' }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/restart', expect.objectContaining({ method: 'POST' })); }); }); // ========================================================================= // Application Environment Variables // ========================================================================= describe('application environment variables', () => { const mockEnvVar = { uuid: 'env-var-uuid', key: 'API_KEY', value: 'secret123', is_build_time: false, }; it('should list application env vars', async () => { mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar])); const result = await client.listApplicationEnvVars('app-uuid'); expect(result).toEqual([mockEnvVar]); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/envs', expect.any(Object)); }); it('should list application env vars with summary', async () => { const fullEnvVar = { id: 1, uuid: 'env-var-uuid', key: 'API_KEY', value: 'secret123', is_build_time: false, is_literal: true, is_multiline: false, is_preview: false, is_shared: false, is_shown_once: false, application_id: 1, created_at: '2024-01-01', updated_at: '2024-01-01', }; mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar])); const result = await client.listApplicationEnvVars('app-uuid', { summary: true }); // Summary should only include uuid, key, value, is_build_time expect(result).toEqual([ { uuid: 'env-var-uuid', key: 'API_KEY', value: 'secret123', is_build_time: false, }, ]); }); it('should create application env var', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' })); const result = await client.createApplicationEnvVar('app-uuid', { key: 'NEW_VAR', value: 'new-value', is_bu