Newer
Older
navi-1 / webclient / tests / unit / components / settings / skillsCatalogPanel.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.js'
import SkillsCatalogPanel from '@/components/settings/SkillsCatalogPanel.vue'

vi.mock('@/api/index.js', () => ({
  getSkills: vi.fn(),
  getSkill: vi.fn(),
  createSkill: vi.fn(),
  updateSkill: vi.fn(),
  deleteSkill: vi.fn(),
  getSkillVersions: vi.fn(),
}))

vi.mock('@/composables/useConfirm.js', () => ({
  useConfirm: () => () => Promise.resolve(true),
}))

// No GnToastProvider in tests — swallow toast calls.
vi.mock('gnexus-ui-kit/vue', async (importOriginal) => {
  const mod = await importOriginal()
  const toast = {
    show: () => {}, info: () => {}, success: () => {},
    warning: () => {}, danger: () => {}, error: () => {}, close: () => {},
  }
  return { ...mod, useToast: () => toast }
})

import * as api from '@/api/index.js'

const SKILL = {
  id: 'sk-1', kind: 'skill', name: 'Разбор логов',
  description: 'Быстрый поиск причины в логах.',
  area: 'workflows', tags: ['logs'], visibility: 'personal',
  version: 2, owner_id: 'u1', can_edit: true,
  updated_at: '2026-11-01T10:00:00+00:00',
  content: '---\nkind: skill\nname: Разбор логов\n---\n\nГрепай ERROR.\n',
  document: {
    kind: 'skill', name: 'Разбор логов', description: 'Быстрый поиск причины в логах.',
    area: 'workflows', tags: ['logs'], triggers: ['нужно найти ошибку'],
    steps: [], preconditions: [], post_checks: [],
    on_deviation: '', on_blocked: '', on_already_done: '',
    body: 'Грепай ERROR, потом сужай по времени.',
  },
}

const PROTOCOL = {
  id: 'pr-1', kind: 'protocol', name: 'Выключение',
  description: 'Порядок выключения.',
  area: 'infrastructure', tags: ['power'], visibility: 'shared',
  version: 1, owner_id: '', can_edit: false,
  updated_at: '2026-11-02T10:00:00+00:00',
  content: '---\nkind: protocol\nname: Выключение\n---\n',
  document: {
    kind: 'protocol', name: 'Выключение', description: 'Порядок выключения.',
    area: 'infrastructure', tags: ['power'], triggers: ['выключить сервер'],
    steps: ['Остановить сервисы', 'Отмонтировать диски'],
    preconditions: ['Все предупреждены'],
    post_checks: ['Юнитов не осталось'],
    on_deviation: 'Остановиться и доложить.',
    on_blocked: 'Прервать и доложить.',
    on_already_done: 'Проверить и пропустить.',
    body: 'Порядок зависимостей.',
  },
}

function settle() {
  return new Promise(r => setTimeout(r))
}

function mountPanel(kind = 'skill') {
  return mount(SkillsCatalogPanel, { props: { kind } })
}

function button(wrapper, text) {
  return wrapper.findAll('button').find(b => b.text().trim() === text)
}

describe('SkillsCatalogPanel', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
    useAuthStore().user = { id: 'u1', role: 'user', permissions: [] }
    api.getSkills.mockImplementation(async ({ kind }) => ({
      items: kind === 'protocol'
        ? [structuredClone(PROTOCOL)]
        : [structuredClone(SKILL)],
    }))
    api.getSkill.mockImplementation(async (id) => (
      id === 'pr-1' ? structuredClone(PROTOCOL) : structuredClone(SKILL)
    ))
    api.updateSkill.mockImplementation(async (id, payload) => ({
      ...structuredClone(SKILL), content: payload.content,
    }))
    api.createSkill.mockImplementation(async (payload) => ({
      ...structuredClone(SKILL), content: payload.content, version: 1,
    }))
    api.getSkillVersions.mockResolvedValue({ items: [] })
  })

  it('asks the API for its own kind and lists only that kind', async () => {
    const wrapper = mountPanel('skill')
    await settle()

    expect(api.getSkills).toHaveBeenCalledWith(
      expect.objectContaining({ kind: 'skill' }),
    )
    expect(wrapper.findAll('.catalog-row')).toHaveLength(1)
    expect(wrapper.text()).toContain('Разбор логов')
    expect(wrapper.text()).not.toContain('Выключение')
  })

  it('the protocols section asks for protocols', async () => {
    const wrapper = mountPanel('protocol')
    await settle()

    expect(api.getSkills).toHaveBeenCalledWith(
      expect.objectContaining({ kind: 'protocol' }),
    )
    expect(wrapper.text()).toContain('Выключение')
  })

  it('shows the entry as a document, not as its source', async () => {
    const wrapper = mountPanel('protocol')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()

    expect(api.getSkill).toHaveBeenCalledWith('pr-1')
    expect(wrapper.find('textarea').exists()).toBe(false)
    expect(wrapper.text()).toContain('When to use it')
    expect(wrapper.text()).toContain('выключить сервер')
    expect(wrapper.text()).toContain('Остановить сервисы')
    expect(wrapper.text()).toContain('Остановиться и доложить.')
    expect(wrapper.text()).toContain('Порядок зависимостей.')
  })

  it('opens the source only when asked to edit it', async () => {
    const wrapper = mountPanel('skill')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()
    expect(wrapper.find('textarea').exists()).toBe(false)

    await wrapper.find('.catalog-edit-btn').trigger('click')
    await settle()

    expect(wrapper.find('textarea').element.value).toContain('kind: skill')
    expect(button(wrapper, 'Save')).toBeTruthy()
  })

  it('saves the edited source back to the server', async () => {
    const wrapper = mountPanel('skill')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()
    await wrapper.find('.catalog-edit-btn').trigger('click')
    await settle()

    await wrapper.find('textarea').setValue('---\nkind: skill\nname: Разбор логов\n---\n\nНовое тело.\n')
    await button(wrapper, 'Save').trigger('click')
    await settle()

    expect(api.updateSkill).toHaveBeenCalledWith('sk-1', {
      content: expect.stringContaining('Новое тело.'),
    })
    // Back to the document once the server accepted it.
    expect(wrapper.find('textarea').exists()).toBe(false)
  })

  it('a shared entry is read-only for a regular user', async () => {
    const wrapper = mountPanel('protocol')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()

    expect(wrapper.find('.catalog-readonly').exists()).toBe(true)
    expect(wrapper.find('.catalog-edit-btn').exists()).toBe(false)
    expect(wrapper.find('.catalog-delete-btn').exists()).toBe(false)
  })

  it('an admin can choose the visibility while editing', async () => {
    useAuthStore().user = { id: 'u1', role: 'admin', permissions: [] }
    const wrapper = mountPanel('skill')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()
    await wrapper.find('.catalog-edit-btn').trigger('click')
    await settle()

    expect(wrapper.find('.catalog-visibility').exists()).toBe(true)
  })

  it('a new entry starts from a template with the escape hatches', async () => {
    const wrapper = mountPanel('protocol')
    await settle()

    await button(wrapper, 'New protocol').trigger('click')
    await settle()

    const text = wrapper.find('textarea').element.value
    expect(text).toContain('kind: protocol')
    expect(text).toContain('on_deviation:')
    expect(text).toContain('post_checks:')
  })

  it('shows the server validation message when a protocol is refused', async () => {
    api.createSkill.mockRejectedValue(
      new Error('POST /skills → 400: {"detail":"Protocol needs: post_checks, on_blocked"}'),
    )
    const wrapper = mountPanel('protocol')
    await settle()

    await button(wrapper, 'New protocol').trigger('click')
    await settle()

    await button(wrapper, 'Save').trigger('click')
    await settle()

    expect(api.createSkill).toHaveBeenCalled()
    expect(wrapper.find('.catalog-save-error').text()).toContain(
      'Protocol needs: post_checks, on_blocked',
    )
  })

  it('falls back to the source when the stored document does not parse', async () => {
    api.getSkill.mockResolvedValue({
      ...structuredClone(SKILL), document: null, content: '---\nname: битый\n---\n',
    })
    const wrapper = mountPanel('skill')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()

    expect(wrapper.find('.catalog-parse-error').exists()).toBe(true)
    expect(wrapper.find('.catalog-raw').text()).toContain('name: битый')
  })

  it('loads the edit history on demand', async () => {
    api.getSkillVersions.mockResolvedValue({
      items: [{ version: 1, edited_by: 'u1', created_at: '2026-11-01T10:00:00+00:00', content: 'v1' }],
    })
    const wrapper = mountPanel('skill')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()
    await wrapper.find('.catalog-history-toggle').trigger('click')
    await settle()

    expect(api.getSkillVersions).toHaveBeenCalledWith('sk-1')
    expect(wrapper.text()).toContain('v1')
  })

  it('the back control returns to the list', async () => {
    const wrapper = mountPanel('skill')
    await settle()

    await wrapper.find('.catalog-row').trigger('click')
    await settle()
    expect(wrapper.find('.catalog-back').exists()).toBe(true)

    await wrapper.find('.catalog-back').trigger('click')
    await settle()

    expect(wrapper.find('h3').exists()).toBe(false)
  })

  it('the area filter is sent to the API', async () => {
    const wrapper = mountPanel('skill')
    await settle()
    api.getSkills.mockClear()

    await wrapper.find('select').setValue('home')
    await new Promise(r => setTimeout(r, 300))

    expect(api.getSkills).toHaveBeenCalledWith(
      expect.objectContaining({ area: 'home' }),
    )
  })
})