Newer
Older
navi-1 / webclient / tests / unit / components / settings / skillsPanel.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 SkillsPanel from '@/components/settings/SkillsPanel.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 LIST = [
  {
    id: 'sk-1', kind: 'skill', name: 'Разбор логов',
    description: 'Быстрый поиск причины в логах.',
    area: 'workflows', tags: ['logs'], visibility: 'personal',
    version: 2, owner_id: 'u1', can_edit: true,
  },
  {
    id: 'pr-1', kind: 'protocol', name: 'Выключение',
    description: 'Порядок выключения.',
    area: 'infrastructure', tags: ['power'], visibility: 'shared',
    version: 1, owner_id: '', can_edit: false,
  },
]

const FULL_SKILL = {
  ...LIST[0],
  content: '---\nkind: skill\nname: Разбор логов\n---\n\nГрепай ERROR.\n',
}

const PROTOCOL_DOC = '---\nkind: protocol\nname: Выключение\n---\n'

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

describe('SkillsPanel', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
    useAuthStore().user = { id: 'u1', role: 'user', permissions: [] }
    api.getSkills.mockResolvedValue({ items: structuredClone(LIST) })
    api.getSkill.mockImplementation(async (id) => (
      id === 'pr-1'
        ? { ...structuredClone(LIST[1]), content: PROTOCOL_DOC }
        : structuredClone(FULL_SKILL)
    ))
    api.getSkillVersions.mockResolvedValue({ items: [] })
  })

  it('lists both kinds with their kind, area and visibility', async () => {
    const wrapper = mount(SkillsPanel)
    await settle()

    expect(api.getSkills).toHaveBeenCalled()
    expect(wrapper.findAll('.skills-row')).toHaveLength(2)
    expect(wrapper.text()).toContain('Разбор логов')
    expect(wrapper.text()).toContain('protocol')
    expect(wrapper.text()).toContain('Выключение')
  })

  it('filters are sent to the API', async () => {
    const wrapper = mount(SkillsPanel)
    await settle()
    api.getSkills.mockClear()

    // The kind select is the first one in the toolbar.
    const select = wrapper.findAll('select')[0]
    await select.setValue('protocol')
    await settle()
    await new Promise(r => setTimeout(r, 300))

    expect(api.getSkills).toHaveBeenCalledWith(
      expect.objectContaining({ kind: 'protocol' }),
    )
  })

  it('opens an entry and shows the server document', async () => {
    const wrapper = mount(SkillsPanel)
    await settle()

    await wrapper.findAll('.skills-row')[0].trigger('click')
    await settle()

    expect(api.getSkill).toHaveBeenCalledWith('sk-1')
    expect(wrapper.find('textarea').element.value).toContain('Грепай ERROR')
    expect(wrapper.find('.skills-editor-title').text()).toContain('Разбор логов')
  })

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

    await wrapper.findAll('.skills-row')[1].trigger('click')
    await settle()

    expect(wrapper.find('.skills-readonly').exists()).toBe(true)
    expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
    // No visibility control: only an admin can move an entry between scopes.
    expect(wrapper.findAll('select')).toHaveLength(2)
  })

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

    await wrapper.findAll('.skills-row')[0].trigger('click')
    await settle()

    // Two toolbar filters plus the editor's visibility select.
    expect(wrapper.findAll('select')).toHaveLength(3)
  })

  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 = mount(SkillsPanel)
    await settle()

    const newProtocol = wrapper.findAll('button').find(b => b.text().includes('New protocol'))
    await newProtocol.trigger('click')
    await settle()

    const save = wrapper.findAll('button').find(b => b.text().trim() === 'Save')
    await save.trigger('click')
    await settle()

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

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

    const newProtocol = wrapper.findAll('button').find(b => b.text().includes('New protocol'))
    await newProtocol.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:')
  })
})