kin/web/frontend/src/__tests__/claude-auth.test.ts

277 lines
9.5 KiB
TypeScript

/**
* KIN-083: Тесты healthcheck Claude CLI auth — frontend баннеры
*
* Проверяет:
* 1. TaskDetail.vue: при ошибке claude_auth_required от runTask — показывает баннер
* 2. TaskDetail.vue: баннер закрывается кнопкой ✕
* 3. TaskDetail.vue: happy path — баннер не появляется при успешном runTask
* 4. ProjectView.vue: при ошибке claude_auth_required от startPhase — показывает баннер
* 5. ProjectView.vue: happy path — баннер не появляется при успешном startPhase
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import TaskDetail from '../views/TaskDetail.vue'
import ProjectView from '../views/ProjectView.vue'
// importOriginal сохраняет реальный ApiError — нужен для instanceof-проверки в компоненте
vi.mock('../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api')>()
return {
...actual,
api: {
project: vi.fn(),
taskFull: vi.fn(),
runTask: vi.fn(),
startPhase: vi.fn(),
getPhases: vi.fn(),
patchTask: vi.fn(),
patchProject: vi.fn(),
auditProject: vi.fn(),
createTask: vi.fn(),
deployProject: vi.fn(),
notifications: vi.fn(),
},
}
})
import { api, ApiError } from '../api'
const Stub = { template: '<div />' }
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v },
removeItem: (k: string) => { delete store[k] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/project/:id', component: ProjectView, props: true },
{ path: '/task/:id', component: TaskDetail, props: true },
],
})
}
const MOCK_TASK = {
id: 'KIN-001',
project_id: 'KIN',
title: 'Тестовая задача',
status: 'pending',
priority: 5,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
execution_mode: null,
blocked_reason: null,
dangerously_skipped: null,
category: null,
acceptance_criteria: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
pending_actions: [],
}
const MOCK_PROJECT = {
id: 'KIN',
name: 'Kin',
path: '/projects/kin',
status: 'active',
priority: 5,
tech_stack: ['python', 'vue'],
execution_mode: 'review',
autocommit_enabled: 0,
obsidian_vault_path: null,
deploy_command: null,
created_at: '2024-01-01',
total_tasks: 1,
done_tasks: 0,
active_tasks: 1,
blocked_tasks: 0,
review_tasks: 0,
project_type: 'development',
ssh_host: null,
ssh_user: null,
ssh_key_path: null,
ssh_proxy_jump: null,
description: null,
tasks: [],
decisions: [],
modules: [],
}
const MOCK_ACTIVE_PHASE = {
id: 1,
project_id: 'KIN',
role: 'pm',
phase_order: 1,
status: 'active',
task_id: 'KIN-R-001',
revise_count: 0,
revise_comment: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
task: {
id: 'KIN-R-001',
status: 'pending',
title: 'Research',
priority: 5,
assigned_role: 'pm',
parent_task_id: null,
brief: null,
spec: null,
execution_mode: null,
blocked_reason: null,
dangerously_skipped: null,
category: null,
acceptance_criteria: null,
project_id: 'KIN',
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
}
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
vi.mocked(api.taskFull).mockResolvedValue(MOCK_TASK as any)
vi.mocked(api.runTask).mockResolvedValue({ status: 'started' } as any)
vi.mocked(api.startPhase).mockResolvedValue({ status: 'started', phase_id: 1, task_id: 'KIN-R-001' })
vi.mocked(api.getPhases).mockResolvedValue([])
vi.mocked(api.notifications).mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
// ─────────────────────────────────────────────────────────────
// TaskDetail: баннер при claude_auth_required
// ─────────────────────────────────────────────────────────────
describe('KIN-083: TaskDetail — claude auth banner', () => {
async function mountTaskDetail() {
const router = makeRouter()
await router.push('/task/KIN-001')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-001' },
global: { plugins: [router] },
})
await flushPromises()
return wrapper
}
it('показывает баннер "Claude CLI requires login" при ошибке claude_auth_required от runTask', async () => {
vi.mocked(api.runTask).mockRejectedValue(
new ApiError('claude_auth_required', 'Claude CLI requires login. Run: claude login'),
)
const wrapper = await mountTaskDetail()
const runBtn = wrapper.findAll('button').find(b => b.text().includes('Run Pipeline'))
expect(runBtn?.exists(), 'Кнопка Run Pipeline должна быть видна для pending задачи').toBe(true)
await runBtn!.trigger('click')
await flushPromises()
expect(wrapper.text(), 'Баннер должен содержать текст ошибки аутентификации').toContain('Claude CLI requires login')
})
it('баннер закрывается кнопкой ✕', async () => {
vi.mocked(api.runTask).mockRejectedValue(
new ApiError('claude_auth_required', 'Claude CLI requires login. Run: claude login'),
)
const wrapper = await mountTaskDetail()
const runBtn = wrapper.findAll('button').find(b => b.text().includes('Run Pipeline'))
await runBtn!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Claude CLI requires login')
const closeBtn = wrapper.findAll('button').find(b => b.text().trim() === '✕')
expect(closeBtn?.exists(), 'Кнопка ✕ должна быть видна').toBe(true)
await closeBtn!.trigger('click')
await flushPromises()
expect(wrapper.text(), 'После закрытия баннер не должен быть виден').not.toContain('Claude CLI requires login')
})
it('не показывает баннер когда runTask успешен (happy path)', async () => {
const wrapper = await mountTaskDetail()
const runBtn = wrapper.findAll('button').find(b => b.text().includes('Run Pipeline'))
if (runBtn?.exists()) {
await runBtn.trigger('click')
await flushPromises()
}
expect(wrapper.text(), 'Баннер не должен появляться при успешном запуске').not.toContain('Claude CLI requires login')
})
})
// ─────────────────────────────────────────────────────────────
// ProjectView: баннер при claude_auth_required
// ─────────────────────────────────────────────────────────────
describe('KIN-083: ProjectView — claude auth banner', () => {
async function mountOnPhases() {
vi.mocked(api.getPhases).mockResolvedValue([MOCK_ACTIVE_PHASE] as any)
const router = makeRouter()
await router.push('/project/KIN')
const wrapper = mount(ProjectView, {
props: { id: 'KIN' },
global: { plugins: [router] },
})
await flushPromises()
const phasesTab = wrapper.findAll('button').find(b => b.text().includes('Phases'))
await phasesTab!.trigger('click')
await flushPromises()
return wrapper
}
it('показывает баннер "Claude CLI requires login" при ошибке claude_auth_required от startPhase', async () => {
vi.mocked(api.startPhase).mockRejectedValue(
new ApiError('claude_auth_required', 'Claude CLI requires login. Run: claude login'),
)
const wrapper = await mountOnPhases()
const startBtn = wrapper.findAll('button').find(b => b.text().includes('Start Research'))
expect(startBtn?.exists(), 'Кнопка Start Research должна быть видна').toBe(true)
await startBtn!.trigger('click')
await flushPromises()
expect(wrapper.text(), 'Баннер должен содержать текст ошибки аутентификации').toContain('Claude CLI requires login')
})
it('не показывает баннер когда startPhase успешен (happy path)', async () => {
const wrapper = await mountOnPhases()
const startBtn = wrapper.findAll('button').find(b => b.text().includes('Start Research'))
if (startBtn?.exists()) {
await startBtn.trigger('click')
await flushPromises()
}
expect(wrapper.text(), 'Баннер не должен появляться при успешном запуске фазы').not.toContain('Claude CLI requires login')
})
})