import { describe, it, expect, vi, afterEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import { createRouter, createMemoryHistory } from 'vue-router'; import { createPinia, setActivePinia } from 'pinia'; import DealsView from '../../resources/js/views/DealsView.vue'; import * as dealsApi from '../../resources/js/api/deals'; import { useAuthStore } from '../../resources/js/stores/auth'; import type { AuthUser } from '../../resources/js/api/auth'; import type { MockDeal } from '../../resources/js/composables/mockDeals'; function apiDeal(id: number, over: Partial = {}): dealsApi.ApiDeal { return { id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`, contact_name: null, status: 'new', manager_id: null, manager_name: null, manager_initials: null, received_at: '2026-05-15T09:00:00+00:00', comment: null, city: null, project_signal_type: 'call', next_reminder_at: null, ...over, }; } async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) { setActivePinia(createPinia()); const auth = useAuthStore(); auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser; const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 }); vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([ { id: 1, name: 'Окна', tag: null, type: 'supplier' }, ]); const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }], }); await router.push('/deals'); await router.isReady(); const wrapper = mount(DealsView, { global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } }, }); await flushPromises(); // Reset call history so subsequent vi.spyOn calls in tests start from count=0. dealsSpy.mockClear(); return wrapper; } describe('DealsView.vue — реестр лидов', () => { it('заголовок «Сделки»', async () => { expect((await mountDeals()).find('h1').text()).toBe('Сделки'); }); it('панель экспорта: поля дат + кнопки Excel/CSV', async () => { const w = await mountDeals(); expect(w.find('[data-testid="export-from"]').exists()).toBe(true); expect(w.find('[data-testid="export-to"]').exists()).toBe(true); expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true); expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true); }); it('селектор «Показывать по» с вариантами 10/20/50', async () => { const w = await mountDeals(); const toggle = w.find('[data-testid="perpage-toggle"]'); expect(toggle.exists()).toBe(true); ['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n)); }); it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => { const w = await mountDeals(); // «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` — // редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно // КНОПКИ создания сделки: ручное создание убрано в реестре лидов. const buttons = w.findAll('button'); expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false); expect(w.text()).not.toContain('Корзина'); }); it('загружает сделки в dealsState через API', async () => { const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3); const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number }; expect(vm.dealsState.length).toBe(3); expect(vm.total).toBe(3); }); it('openPanel выбирает сделку, повторный клик закрывает', async () => { const w = await mountDeals(); const vm = w.vm as unknown as { dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null; openPanel: (d: MockDeal) => void; }; vm.openPanel(vm.dealsState[0]); expect(vm.panelOpen).toBe(true); expect(vm.selectedDeal?.id).toBe(1); vm.openPanel(vm.dealsState[0]); expect(vm.panelOpen).toBe(false); }); it('при selected=1 drawer авто-открывается, bulk-полоса скрыта (18.05.2026 ux)', async () => { const w = await mountDeals(); const vm = w.vm as unknown as { selected: number[]; panelOpen: boolean; selectedDeal: MockDeal | null; }; vm.selected = [1]; await flushPromises(); expect(vm.panelOpen).toBe(true); expect(vm.selectedDeal?.id).toBe(1); expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false); }); it('при selected≥2 drawer закрывается, bulk-полоса видна (18.05.2026 ux)', async () => { const w = await mountDeals(); const vm = w.vm as unknown as { selected: number[]; panelOpen: boolean; dealsState: MockDeal[]; openPanel: (d: MockDeal) => void; }; vm.openPanel(vm.dealsState[0]); expect(vm.panelOpen).toBe(true); vm.selected = [1, 2]; await flushPromises(); expect(vm.panelOpen).toBe(false); expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true); }); it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => { const w = await mountDeals(); const vm = w.vm as unknown as { selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise; }; vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' }); vm.selected = [1, 2]; await flushPromises(); expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true); await vm.applyBulkStatus('viewed'); expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed'); }); it('exportByRange xlsx вызывает exportDealsByRange', async () => { const w = await mountDeals(); const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob()); Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true }); Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true }); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); const vm = w.vm as unknown as { exportByRange: (f: string) => Promise; exportToastOpen: boolean }; await vm.exportByRange('xlsx'); expect(spy).toHaveBeenCalledOnce(); expect(vm.exportToastOpen).toBe(true); }); it('смена фильтра вызывает loadDeals ровно один раз (без двойного fetch)', async () => { const w = await mountDeals(); const vm = w.vm as unknown as { filterStatus: string | null; page: number }; // Установим spy до смены page, чтобы перехватить все вызовы const spy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals: [], total: 0, limit: 20, offset: 0 }); // Переходим на страницу 3 — это вызовет watch(page) → loadDeals один раз vm.page = 3; await flushPromises(); spy.mockClear(); // сбрасываем счётчик: интересует только смена фильтра // Смена фильтра при page=3: A10 fix должен лишь сбросить page→1 (без прямого loadDeals), // затем watch(page) делает ровно один fetch vm.filterStatus = 'viewed'; await flushPromises(); expect(spy).toHaveBeenCalledTimes(1); }); it('loadDeals reject → dealsState пустой + fetchError', async () => { setActivePinia(createPinia()); const auth = useAuthStore(); auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser; vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500')); vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]); const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] }); await router.push('/deals'); await router.isReady(); const w = mount(DealsView, { global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } }, }); await flushPromises(); const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean }; expect(vm.dealsState.length).toBe(0); expect(vm.fetchError).toBe(true); }); }); afterEach(() => vi.restoreAllMocks());