import { describe, it, expect } from 'vitest'; import { resourceToYaml, resourcePath, parseResourcePath, APPLY_ORDER } from '../src/services/backup/yaml-serializer.js'; describe('resourceToYaml', () => { it('serializes a server', () => { const yaml = resourceToYaml('server', { id: 'srv-1', name: 'grafana', description: 'Grafana MCP', dockerImage: 'mcp/grafana:latest', transport: 'STDIO', env: [{ name: 'API_KEY', value: 'secret' }], version: 1, createdAt: new Date(), updatedAt: new Date(), packageName: null, repositoryUrl: null, }); expect(yaml).toContain('kind: server'); expect(yaml).toContain('name: grafana'); expect(yaml).toContain('description: Grafana MCP'); expect(yaml).toContain('dockerImage: mcp/grafana:latest'); expect(yaml).toContain('transport: STDIO'); expect(yaml).not.toContain('id:'); expect(yaml).not.toContain('createdAt:'); expect(yaml).not.toContain('version:'); expect(yaml).not.toContain('packageName:'); // null values stripped }); it('serializes a project with server names', () => { const yaml = resourceToYaml('project', { id: 'p-1', name: 'my-project', description: 'Test project', proxyModel: 'default', gated: true, ownerId: 'user-1', servers: [ { id: 'ps-1', server: { name: 'grafana' } }, { id: 'ps-2', server: { name: 'node-red' } }, ], llmProvider: 'openai', llmModel: null, version: 1, createdAt: new Date(), updatedAt: new Date(), }); expect(yaml).toContain('kind: project'); expect(yaml).toContain('name: my-project'); expect(yaml).toContain('proxyModel: default'); expect(yaml).toContain('- grafana'); expect(yaml).toContain('- node-red'); expect(yaml).toContain('llmProvider: openai'); expect(yaml).not.toContain('gated:'); expect(yaml).not.toContain('ownerId:'); expect(yaml).not.toContain('llmModel:'); // null stripped }); it('normalizes proxyModel from gated boolean', () => { const yaml1 = resourceToYaml('project', { name: 'p1', proxyModel: '', gated: false, servers: [], }); expect(yaml1).toContain('proxyModel: content-pipeline'); const yaml2 = resourceToYaml('project', { name: 'p2', proxyModel: '', gated: true, servers: [], }); expect(yaml2).toContain('proxyModel: default'); }); it('serializes a secret', () => { const yaml = resourceToYaml('secret', { id: 's-1', name: 'my-secret', data: { TOKEN: 'abc123', KEY: 'xyz' }, version: 1, createdAt: new Date(), updatedAt: new Date(), }); expect(yaml).toContain('kind: secret'); expect(yaml).toContain('name: my-secret'); expect(yaml).toContain('TOKEN: abc123'); expect(yaml).toContain('KEY: xyz'); }); it('serializes a user without passwordHash', () => { const yaml = resourceToYaml('user', { id: 'u-1', email: 'michal@test.com', name: 'Michal', role: 'ADMIN', passwordHash: '$2b$10$secret', version: 1, createdAt: new Date(), updatedAt: new Date(), }); expect(yaml).toContain('kind: user'); expect(yaml).toContain('email: michal@test.com'); expect(yaml).toContain('name: Michal'); expect(yaml).toContain('role: ADMIN'); expect(yaml).not.toContain('passwordHash'); }); it('serializes a group with member emails', () => { const yaml = resourceToYaml('group', { id: 'g-1', name: 'dev-team', description: 'Developers', members: [ { user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }, ], version: 1, createdAt: new Date(), updatedAt: new Date(), }); expect(yaml).toContain('kind: group'); expect(yaml).toContain('name: dev-team'); expect(yaml).toContain('- alice@test.com'); expect(yaml).toContain('- bob@test.com'); }); it('serializes a prompt with project name', () => { const yaml = resourceToYaml('prompt', { id: 'pr-1', name: 'system-instructions', content: 'You are a helpful assistant.', priority: 5, project: { name: 'my-project' }, projectId: 'p-1', summary: 'Summary text', chapters: ['ch1'], linkTarget: null, version: 1, createdAt: new Date(), updatedAt: new Date(), }); expect(yaml).toContain('kind: prompt'); expect(yaml).toContain('name: system-instructions'); expect(yaml).toContain('project: my-project'); expect(yaml).toContain('priority: 5'); expect(yaml).toContain('content: You are a helpful assistant.'); expect(yaml).not.toContain('projectId:'); expect(yaml).not.toContain('summary:'); expect(yaml).not.toContain('chapters:'); }); it('serializes a linked prompt with link field', () => { const yaml = resourceToYaml('prompt', { id: 'pr-2', name: 'linked-prompt', content: 'Fetched content', linkTarget: 'my-project/grafana:resource://docs', project: { name: 'my-project' }, projectId: 'p-1', priority: 3, version: 1, createdAt: new Date(), updatedAt: new Date(), }); expect(yaml).toContain('link: my-project/grafana:resource://docs'); expect(yaml).not.toContain('content:'); // content stripped for linked prompts expect(yaml).not.toContain('linkTarget:'); }); it('puts kind first and content/data last', () => { const yaml = resourceToYaml('secret', { name: 'test', data: { KEY: 'val' }, }); const lines = yaml.split('\n'); expect(lines[0]).toBe('kind: secret'); // data should be after name const nameIdx = lines.findIndex((l) => l.startsWith('name:')); const dataIdx = lines.findIndex((l) => l.startsWith('data:')); expect(dataIdx).toBeGreaterThan(nameIdx); }); }); describe('resourcePath', () => { it('maps kinds to directories', () => { expect(resourcePath('server', 'grafana')).toBe('servers/grafana.yaml'); expect(resourcePath('secret', 'my-token')).toBe('secrets/my-token.yaml'); expect(resourcePath('project', 'default')).toBe('projects/default.yaml'); expect(resourcePath('rbac', 'admins')).toBe('rbac/admins.yaml'); expect(resourcePath('user', 'michal@test.com')).toBe('users/michal@test.com.yaml'); }); it('sanitizes unsafe characters', () => { expect(resourcePath('server', 'my/server')).toBe('servers/my_server.yaml'); }); }); describe('parseResourcePath', () => { it('parses valid paths', () => { expect(parseResourcePath('servers/grafana.yaml')).toEqual({ kind: 'server', name: 'grafana' }); expect(parseResourcePath('secrets/my-token.yaml')).toEqual({ kind: 'secret', name: 'my-token' }); expect(parseResourcePath('rbac/admins.yaml')).toEqual({ kind: 'rbac', name: 'admins' }); }); it('returns null for invalid paths', () => { expect(parseResourcePath('README.md')).toBeNull(); expect(parseResourcePath('.gitkeep')).toBeNull(); expect(parseResourcePath('unknown/file.yaml')).toBeNull(); }); }); describe('APPLY_ORDER', () => { it('has secrets before servers before projects', () => { const si = APPLY_ORDER.indexOf('secret'); const sv = APPLY_ORDER.indexOf('server'); const pr = APPLY_ORDER.indexOf('project'); expect(si).toBeLessThan(sv); expect(sv).toBeLessThan(pr); }); it('has all backup kinds', () => { // PR-3: bumped from 8 → 9 with the addition of `skill`. expect(APPLY_ORDER).toHaveLength(9); }); });