kin/web/frontend/src/__tests__/chat-view-polling-errors.test.ts

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* KIN-UI-008: ChatView — логирование ошибок в polling-цикле
*
* Проверяет:
* 1. console.warn вызывается при каждой ошибке polling
* 2. После 3 последовательных ошибок — error.value устанавливается
* 3. После 3 ошибок — polling останавливается
* 4. Счётчик сбрасывается при успешном ответе (error.value тоже сбрасывается)
* 5. Менее 3 ошибок — error.value не устанавливается
* 6. load() сбрасывает consecutiveErrors до начала загрузки
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ChatView from '../views/ChatView.vue'
vi.mock('../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api')>()
return {
...actual,
api: {
chatHistory: vi.fn(),
project: vi.fn(),
sendChatMessage: vi.fn(),
},
}
})
import { api } from '../api'
const Stub = { template: '<div />' }
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/project/:id', component: Stub },
{ path: '/chat/:projectId', component: ChatView, props: true },
],
})
}
const MOCK_MESSAGES_IDLE = [
{
id: 1,
project_id: 'KIN',
role: 'user',
content: 'Привет',
message_type: 'text',
task_stub: null,
created_at: '2024-01-01T00:00:00',
},
]
const MOCK_MESSAGES_WITH_RUNNING_TASK = [
{
id: 1,
project_id: 'KIN',
role: 'assistant',
content: 'Работаю...',
message_type: 'task_created',
task_stub: { id: 'KIN-001', status: 'in_progress' },
created_at: '2024-01-01T00:00:00',
},
]
const MOCK_PROJECT = {
id: 'KIN',
name: 'Kin',
path: '/projects/kin',
status: 'active',
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
async function mountChatView(projectId = 'KIN') {
const router = makeRouter()
await router.push(`/chat/${projectId}`)
const wrapper = mount(ChatView, {
props: { projectId },
global: { plugins: [router] },
})
return wrapper
}
describe('KIN-UI-008: ChatView — polling error handling', () => {
describe('console.warn при ошибках polling', () => {
it('console.warn вызывается при первой ошибке polling', async () => {
// Первый вызов (load) — успех, второй (polling) — ошибка
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValue(new Error('Network error'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
// Триггерим первый тик polling
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
// Фильтруем только polling-предупреждения (игнорируем Vue Router warn)
const pollingCalls = warnSpy.mock.calls.filter(
args => typeof args[0] === 'string' && args[0].includes('[polling]'),
)
expect(pollingCalls).toHaveLength(1)
expect(pollingCalls[0][0]).toContain('[polling] ошибка #1:')
expect(pollingCalls[0][1]).toBeInstanceOf(Error)
wrapper.unmount()
})
it('console.warn содержит нарастающий номер ошибки при нескольких сбоях', async () => {
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValue(new Error('Server down'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
// Фильтруем только polling-предупреждения
const pollingCalls = warnSpy.mock.calls.filter(
args => typeof args[0] === 'string' && args[0].includes('[polling]'),
)
expect(pollingCalls).toHaveLength(2)
expect(pollingCalls[0][0]).toContain('#1:')
expect(pollingCalls[1][0]).toContain('#2:')
wrapper.unmount()
})
})
describe('error.value после 3 последовательных ошибок', () => {
it('error.value устанавливается после 3 ошибок подряд', async () => {
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValue(new Error('Server down'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
// Ошибки 1, 2, 3
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).toContain('Сервер недоступен')
wrapper.unmount()
})
it('error.value НЕ устанавливается после 1 ошибки', async () => {
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValueOnce(new Error('Transient error'))
.mockResolvedValue(MOCK_MESSAGES_IDLE as any)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
// Только 1 ошибка, потом успех
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).not.toContain('Сервер недоступен')
wrapper.unmount()
})
it('error.value НЕ устанавливается после 2 ошибок подряд', async () => {
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValueOnce(new Error('err 1'))
.mockRejectedValueOnce(new Error('err 2'))
.mockResolvedValue(MOCK_MESSAGES_IDLE as any)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).not.toContain('Сервер недоступен')
wrapper.unmount()
})
})
describe('сброс счётчика при успешном ответе', () => {
it('после успешного ответа (reload через смену projectId) error.value очищается', async () => {
// Первый проект: 3 ошибки → error.value устанавливается
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValueOnce(new Error('err 1'))
.mockRejectedValueOnce(new Error('err 2'))
.mockRejectedValueOnce(new Error('err 3'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView('KIN')
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).toContain('Сервер недоступен')
// Переключаемся на другой проект — вызывает load(), который сбрасывает error.value
vi.mocked(api.chatHistory).mockResolvedValue(MOCK_MESSAGES_IDLE as any)
await wrapper.setProps({ projectId: 'KIN2' })
await flushPromises()
expect(wrapper.text()).not.toContain('Сервер недоступен')
wrapper.unmount()
})
it('после 2 ошибок и 1 успеха — следующие 3 ошибки снова вызывают error.value', async () => {
vi.mocked(api.chatHistory)
// load()
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
// 2 ошибки
.mockRejectedValueOnce(new Error('err'))
.mockRejectedValueOnce(new Error('err'))
// успех — сброс счётчика
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
// ещё 3 ошибки — снова должна появиться ошибка
.mockRejectedValueOnce(new Error('err'))
.mockRejectedValueOnce(new Error('err'))
.mockRejectedValue(new Error('err'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
// 2 ошибки (счётчик = 2, ошибки нет)
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).not.toContain('Сервер недоступен')
// Успех (счётчик = 0)
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
// Ещё 3 ошибки — счётчик начинается с 0, достигает 3
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(wrapper.text()).toContain('Сервер недоступен')
wrapper.unmount()
})
})
describe('остановка polling после 3 ошибок', () => {
it('polling останавливается после 3 ошибок — дополнительные тики не вызывают api', async () => {
vi.mocked(api.chatHistory)
.mockResolvedValueOnce(MOCK_MESSAGES_WITH_RUNNING_TASK as any)
.mockRejectedValue(new Error('Server down'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = await mountChatView()
await flushPromises()
// 3 тика — достигаем лимита
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
const callCountAfterStop = vi.mocked(api.chatHistory).mock.calls.length
// Ещё 3 тика — polling должен быть остановлен, новых вызовов нет
await vi.advanceTimersByTimeAsync(9000)
await flushPromises()
expect(vi.mocked(api.chatHistory).mock.calls.length).toBe(callCountAfterStop)
wrapper.unmount()
})
})
})