import { describe, it, expect, vi, beforeEach } from 'vitest'; import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createApplyCommand } from '../../src/commands/apply.js'; import type { ApiClient } from '../../src/api-client.js'; function mockClient(): ApiClient { return { get: vi.fn(async () => []), post: vi.fn(async () => ({ id: 'new-id', name: 'test' })), put: vi.fn(async () => ({ id: 'existing-id', name: 'test' })), delete: vi.fn(async () => {}), } as unknown as ApiClient; } describe('apply command', () => { let client: ReturnType; let output: string[]; let tmpDir: string; const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-')); }); it('applies servers from YAML file', async () => { const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` servers: - name: slack description: Slack MCP server transport: STDIO packageName: "@anthropic/slack-mcp" `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'slack' })); expect(output.join('\n')).toContain('Created server: slack'); rmSync(tmpDir, { recursive: true, force: true }); }); it('applies servers from JSON file', async () => { const configPath = join(tmpDir, 'config.json'); writeFileSync(configPath, JSON.stringify({ servers: [{ name: 'github', transport: 'STDIO' }], })); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'github' })); expect(output.join('\n')).toContain('Created server: github'); rmSync(tmpDir, { recursive: true, force: true }); }); it('updates existing servers', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'slack' }]); const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` servers: - name: slack description: Updated description transport: STDIO `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ name: 'slack' })); expect(output.join('\n')).toContain('Updated server: slack'); rmSync(tmpDir, { recursive: true, force: true }); }); it('supports dry-run mode', async () => { const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` servers: - name: test transport: STDIO `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' }); expect(client.post).not.toHaveBeenCalled(); expect(output.join('\n')).toContain('Dry run'); expect(output.join('\n')).toContain('1 server(s)'); rmSync(tmpDir, { recursive: true, force: true }); }); it('applies secrets', async () => { const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` secrets: - name: ha-creds data: TOKEN: abc123 URL: https://ha.local `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({ name: 'ha-creds', data: { TOKEN: 'abc123', URL: 'https://ha.local' }, })); expect(output.join('\n')).toContain('Created secret: ha-creds'); rmSync(tmpDir, { recursive: true, force: true }); }); it('updates existing secrets', async () => { vi.mocked(client.get).mockImplementation(async (url: string) => { if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }]; return []; }); const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` secrets: - name: ha-creds data: TOKEN: new-token `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } }); expect(output.join('\n')).toContain('Updated secret: ha-creds'); rmSync(tmpDir, { recursive: true, force: true }); }); it('applies projects', async () => { const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` projects: - name: my-project description: A test project `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ name: 'my-project' })); expect(output.join('\n')).toContain('Created project: my-project'); rmSync(tmpDir, { recursive: true, force: true }); }); });