@masonator/coolify-mcp
Version:
MCP server implementation for Coolify
1,001 lines • 126 kB
JavaScript
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