import { describe, it, expect, vi, beforeEach } from 'vitest'; import { readFileSync, writeFileSync } from 'node:fs'; import yaml from 'js-yaml'; import { createEditCommand } from '../../src/commands/edit.js'; import type { ApiClient } from '../../src/api-client.js'; function mockClient(): ApiClient { return { get: vi.fn(async () => ({})), post: vi.fn(async () => ({})), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } describe('edit command', () => { let client: ReturnType; let output: string[]; const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); beforeEach(() => { client = mockClient(); output = []; }); it('fetches server, opens editor, applies changes on save', async () => { // GET /api/v1/servers returns list for resolveNameOrId vi.mocked(client.get).mockImplementation(async (path: string) => { if (path === '/api/v1/servers') { return [{ id: 'srv-1', name: 'ha-mcp' }]; } // GET /api/v1/servers/srv-1 returns full server return { id: 'srv-1', name: 'ha-mcp', description: 'Old desc', transport: 'STDIO', replicas: 1, createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, }; }); const cmd = createEditCommand({ client, log, getEditor: () => 'vi', openEditor: (filePath) => { // Simulate user editing the file const content = readFileSync(filePath, 'utf-8'); const modified = content .replace('Old desc', 'New desc') .replace('replicas: 1', 'replicas: 3'); writeFileSync(filePath, modified, 'utf-8'); }, }); await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' }); expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ description: 'New desc', replicas: 3, })); expect(output.join('\n')).toContain("server 'ha-mcp' updated"); }); it('detects no changes and skips PUT', async () => { vi.mocked(client.get).mockImplementation(async (path: string) => { if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }]; return { id: 'srv-1', name: 'test', description: '', transport: 'STDIO', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, }; }); const cmd = createEditCommand({ client, log, getEditor: () => 'vi', openEditor: () => { // Don't modify the file }, }); await cmd.parseAsync(['server', 'test'], { from: 'user' }); expect(client.put).not.toHaveBeenCalled(); expect(output.join('\n')).toContain("unchanged"); }); it('handles empty file as cancel', async () => { vi.mocked(client.get).mockImplementation(async (path: string) => { if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }]; return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }; }); const cmd = createEditCommand({ client, log, getEditor: () => 'vi', openEditor: (filePath) => { writeFileSync(filePath, '', 'utf-8'); }, }); await cmd.parseAsync(['server', 'test'], { from: 'user' }); expect(client.put).not.toHaveBeenCalled(); expect(output.join('\n')).toContain('cancelled'); }); it('strips read-only fields from editor content', async () => { vi.mocked(client.get).mockImplementation(async (path: string) => { if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }]; return { id: 'srv-1', name: 'test', description: '', transport: 'STDIO', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, }; }); let editorContent = ''; const cmd = createEditCommand({ client, log, getEditor: () => 'vi', openEditor: (filePath) => { editorContent = readFileSync(filePath, 'utf-8'); }, }); await cmd.parseAsync(['server', 'test'], { from: 'user' }); // The editor content should NOT contain read-only fields expect(editorContent).not.toContain('id:'); expect(editorContent).not.toContain('createdAt'); expect(editorContent).not.toContain('updatedAt'); expect(editorContent).not.toContain('version'); // But should contain editable fields expect(editorContent).toContain('name:'); }); it('rejects edit instance with error message', async () => { const cmd = createEditCommand({ client, log }); await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' }); expect(client.get).not.toHaveBeenCalled(); expect(client.put).not.toHaveBeenCalled(); expect(output.join('\n')).toContain('immutable'); }); });