import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import { createPinia, setActivePinia } from 'pinia'; import KanbanView from '../../resources/js/views/KanbanView.vue'; import { useAuthStore } from '../../resources/js/stores/auth'; import * as dealsApi from '../../resources/js/api/deals'; import { LEAD_STATUSES } from '../../resources/js/composables/leadStatuses'; import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals'; describe('KanbanView.vue', () => { // KanbanView содержит DealDetailDrawer (v-navigation-drawer), который требует // injected layout от v-app — оборачиваем в v-app для теста. // KanbanView содержит DealDetailDrawer (v-navigation-drawer) — stub'им, // т.к. layout-injection недоступна в Vitest. Drawer тестируется отдельно. const factory = () => { setActivePinia(createPinia()); return mount(KanbanView, { global: { plugins: [createVuetify()], stubs: { DealDetailDrawer: true, NewDealDialog: true }, }, }); }; it('монтируется и содержит заголовок «Канбан»', () => { const wrapper = factory(); expect(wrapper.find('h1').text()).toBe('Канбан'); }); it('рендерит ровно 5 KanbanColumn (по числу lead_statuses)', () => { const wrapper = factory(); const cols = wrapper.findAllComponents({ name: 'KanbanColumn' }); expect(cols).toHaveLength(LEAD_STATUSES.length); expect(cols).toHaveLength(5); }); it('каждая колонка получает соответствующий статус', () => { const wrapper = factory(); const cols = wrapper.findAllComponents({ name: 'KanbanColumn' }); cols.forEach((col, i) => { expect(col.props('status').slug).toBe(LEAD_STATUSES[i].slug); }); }); it('содержит page-stats с числом статусов и сделок', () => { const wrapper = factory(); const text = wrapper.text(); expect(text).toContain('5'); expect(text).toContain('статусов'); expect(text).toContain('сделок'); }); it('содержит кнопку «Новая сделка»', () => { const wrapper = factory(); expect(wrapper.text()).toContain('Новая сделка'); }); it('содержит подсказку про перетаскивание (DnD активен)', () => { const wrapper = factory(); expect(wrapper.text()).toMatch(/[Пп]еретаскивание/); }); // Кнопка «Новая сделка» убрана из Канбана (задача 22.06 — создание сделок не с доски). // Тест клика по `new-deal-btn` удалён вместе с кнопкой. it('onDealCreated кладёт сделку в правильную колонку по statusSlug', async () => { const wrapper = factory(); const vm = wrapper.vm as unknown as { dealsByStatus: Record>; onDealCreated: (deal: Record) => void; totalDeals: number; }; const beforeNew = vm.dealsByStatus.new.length; const beforeTotal = vm.totalDeals; // Передаём полную форму deal — Kanban-карточка ожидает manager/cost/etc. vm.onDealCreated({ id: 999, name: 'Тест', phone: '+7 (999) 000-00-00', statusSlug: 'new', project: 'Окна Москва', manager: { initials: 'Т', name: 'Тест' }, cost: 100, receivedMinutesAgo: 0, }); await wrapper.vm.$nextTick(); expect(vm.dealsByStatus.new.length).toBe(beforeNew + 1); expect(vm.dealsByStatus.new[0].id).toBe(999); expect(vm.totalDeals).toBe(beforeTotal + 1); }); it('обновляет statusSlug сделки при drop в новую колонку (event.added)', async () => { const wrapper = factory(); // Засеваем dealsByStatus фикстурой MOCK_DEALS (init теперь пустой). const vm = wrapper.vm as unknown as { dealsByStatus: Record }; for (const deal of MOCK_DEALS) { if (vm.dealsByStatus[deal.statusSlug]) { vm.dealsByStatus[deal.statusSlug].push({ ...deal }); } } await wrapper.vm.$nextTick(); const cols = wrapper.findAllComponents({ name: 'KanbanColumn' }); // Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке. const newCol = cols[0]; // new — sortOrder=1 const wonCol = cols.find((c) => c.props('status').slug === 'won')!; const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0]; // Эмуляция события vuedraggable@change → KanbanView.onColumnChange. await wonCol.vm.$emit('change', { added: { element: dealToMove, newIndex: 0 }, }); await wrapper.vm.$nextTick(); // statusSlug сделки должен переключиться на 'won'. expect(dealToMove.statusSlug).toBe('won'); }); }); // I3 regression: API reject → dealsByStatus пустые + fetchError=true (нет mock-fallback) // Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals. describe('KanbanView I3 regression', () => { it('loadDeals reject оставляет dealsByStatus пустыми и выставляет fetchError', async () => { vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500')); setActivePinia(createPinia()); const auth = useAuthStore(); auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as never; const wrapper = mount(KanbanView, { global: { plugins: [createVuetify()], stubs: { DealDetailDrawer: true, NewDealDialog: true }, }, }); await flushPromises(); const vm = wrapper.vm as unknown as { dealsByStatus: Record; fetchError: boolean; }; expect(vm.fetchError).toBe(true); // Все колонки пусты — нет mock-fallback const allDeals = Object.values(vm.dealsByStatus).flat(); expect(allDeals.length).toBe(0); }); }); describe('KanbanView DnD persist (Sprint 1 C4)', () => { beforeEach(() => { vi.clearAllMocks(); }); it('onColumnChange triggers dealsApi.transitionDeals with [dealId] and target status', async () => { const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 1, requested: 1, status: 'in_progress', }); const wrapper = mount(KanbanView, { global: { plugins: [createPinia(), createVuetify()], stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true }, }, }); const auth = useAuthStore(); auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never; await new Promise((r) => setTimeout(r, 30)); const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } }); expect(transitionSpy).toHaveBeenCalledWith({ tenant_id: 7, ids: [42], status: 'in_progress', }); expect(deal.statusSlug).toBe('in_progress'); }); it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => { vi.spyOn(dealsApi, 'transitionDeals').mockRejectedValue(new Error('500')); const wrapper = mount(KanbanView, { global: { plugins: [createPinia(), createVuetify()], stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true }, }, }); const auth = useAuthStore(); auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never; await new Promise((r) => setTimeout(r, 30)); const deal = { id: 43, statusSlug: 'new' as const, name: 'Y', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5, }; // Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange. // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; if (!vm.dealsByStatus.in_progress) vm.dealsByStatus.in_progress = []; vm.dealsByStatus.in_progress.push(deal); await vm.onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } }); // statusSlug rolled back expect(deal.statusSlug).toBe('new'); // Card removed from target column (array-revert branch coverage) expect(vm.dealsByStatus.in_progress.findIndex((d: { id: number }) => d.id === 43)).toBe(-1); // Card restored to source column expect(vm.dealsByStatus.new.findIndex((d: { id: number }) => d.id === 43)).toBeGreaterThanOrEqual(0); // Toast shown expect(vm.transitionToastOpen).toBe(true); expect(vm.transitionToastText).toContain('Не удалось'); }); it('onColumnChange skips API call if no auth.user.tenant_id', async () => { const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 1, requested: 1, status: 'in_progress', }); const wrapper = mount(KanbanView, { global: { plugins: [createPinia(), createVuetify()], stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true }, }, }); const auth = useAuthStore(); auth.user = null; await new Promise((r) => setTimeout(r, 30)); const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } }); // Без auth — только optimistic local change, API не зовётся expect(transitionSpy).not.toHaveBeenCalled(); expect(deal.statusSlug).toBe('in_progress'); }); });