import { describe, it, expect, vi, beforeEach } 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 KanbanView from '../../resources/js/views/KanbanView.vue'; import { useAuthStore } from '../../resources/js/stores/auth'; import type { ApiDeal } from '../../resources/js/api/deals'; vi.mock('../../resources/js/api/deals', async (importOriginal) => { const orig = await importOriginal(); return { ...orig, listDeals: vi.fn(), listManagers: vi.fn().mockResolvedValue([]), listProjects: vi.fn().mockResolvedValue([]), transitionDeals: vi.fn(), }; }); const dealsApi = await import('../../resources/js/api/deals'); function makeApiDeal(overrides: Partial = {}): ApiDeal { return { id: 100, tenant_id: 1, project_id: 5, project_name: 'Окна Москва', phone: '+7 (999) 100-00-01', contact_name: 'Анна Б.', status: 'new', manager_id: 10, manager_name: 'Иван П.', manager_initials: 'ИП', received_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(), comment: null, city: null, project_signal_type: null, ...overrides, }; } function setupAuth(tenantId: number | null) { setActivePinia(createPinia()); const auth = useAuthStore(); if (tenantId !== null) { auth.user = { id: 1, email: 'test@example.test', first_name: 'Test', last_name: 'User', tenant_id: tenantId, totp_enabled: false, last_login_at: null, }; } } beforeEach(() => { vi.clearAllMocks(); }); const mountDealsView = async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }], }); await router.push('/deals'); await router.isReady(); return mount(DealsView, { global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true }, }, }); }; const mountKanbanView = () => mount(KanbanView, { global: { plugins: [createVuetify()], stubs: { DealDetailDrawer: true, NewDealDialog: true, KanbanColumn: true }, }, }); describe('DealsView ↔ GET /api/deals integration', () => { it('БЕЗ auth.user.tenant_id — listDeals не вызывается, dealsState пустой', async () => { setupAuth(null); const wrapper = await mountDealsView(); await flushPromises(); expect(dealsApi.listDeals).not.toHaveBeenCalled(); const vm = wrapper.vm as unknown as { dealsState: { id: number }[] }; expect(vm.dealsState.length).toBe(0); }); it('С auth.user.tenant_id — listDeals вызывается с tenantId + replace dealsState', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [ makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'won' }), makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }), ], total: 2, limit: 20, offset: 0, }); const wrapper = await mountDealsView(); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 20 })); const vm = wrapper.vm as unknown as { dealsState: { id: number; name: string }[] }; expect(vm.dealsState).toHaveLength(2); expect(vm.dealsState.map((d) => d.id).sort()).toEqual([200, 201]); expect(vm.dealsState.find((d) => d.id === 200)?.name).toBe('Из API #1'); }); it('listDeals reject → fetchError=true, alert виден, dealsState пустой', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockRejectedValueOnce(new Error('network')); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { fetchError: boolean; dealsState: unknown[] }; expect(vm.fetchError).toBe(true); expect(vm.dealsState.length).toBe(0); // Alert виден. expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true); }); it('reload-btn повторно вызывает listDeals', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValue({ deals: [makeApiDeal({ id: 400 })], total: 1, limit: 20, offset: 0, }); const wrapper = await mountDealsView(); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); await wrapper.find('[data-testid="reload-btn"]').trigger('click'); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(2); }); it('applyBulkStatus с tenant_id вызывает transitionDeals + локальный optimistic update', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 500, status: 'new' }), makeApiDeal({ id: 501, status: 'new' })], total: 2, limit: 20, offset: 0, }); vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({ updated: 2, requested: 2, status: 'won', }); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkStatus: (slug: string) => Promise; dealsState: { id: number; statusSlug: string }[]; statusToastOpen: boolean; statusToastText: string; }; vm.selected = [500, 501]; await flushPromises(); await vm.applyBulkStatus('won'); await flushPromises(); // Optimistic local-update применился до завершения API-вызова. expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('won'); expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('won'); expect(dealsApi.transitionDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [500, 501], status: 'won', }); expect(vm.statusToastOpen).toBe(true); expect(vm.statusToastText).toContain('Обновлено 2'); }); it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 600, status: 'new' })], total: 1, limit: 20, offset: 0, }); vi.mocked(dealsApi.transitionDeals).mockRejectedValueOnce(new Error('500')); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkStatus: (slug: string) => Promise; dealsState: { id: number; statusSlug: string }[]; statusToastText: string; }; vm.selected = [600]; await flushPromises(); await vm.applyBulkStatus('won'); await flushPromises(); expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('won'); expect(vm.statusToastText).toContain('Не удалось'); }); }); describe('KanbanView ↔ GET /api/deals integration', () => { it('БЕЗ tenant_id — listDeals не вызывается', async () => { setupAuth(null); mountKanbanView(); await flushPromises(); expect(dealsApi.listDeals).not.toHaveBeenCalled(); }); it('С tenant_id — заполняет колонки по statusSlug + обновляет totalDeals', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [ makeApiDeal({ id: 300, status: 'new' }), makeApiDeal({ id: 301, status: 'won' }), makeApiDeal({ id: 302, status: 'won' }), ], total: 3, limit: 500, offset: 0, }); const wrapper = mountKanbanView(); await flushPromises(); const vm = wrapper.vm as unknown as { dealsByStatus: Record; totalDeals: number; fetchError: boolean; }; expect(vm.dealsByStatus.new.map((d) => d.id)).toEqual([300]); expect(vm.dealsByStatus.won.map((d) => d.id).sort()).toEqual([301, 302]); expect(vm.totalDeals).toBe(3); expect(vm.fetchError).toBe(false); }); it('listDeals reject → fetchError=true, колонки пусты', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockRejectedValueOnce(new Error('500')); const wrapper = mountKanbanView(); await flushPromises(); const vm = wrapper.vm as unknown as { dealsByStatus: Record; fetchError: boolean; }; expect(vm.fetchError).toBe(true); const filledColumns = Object.values(vm.dealsByStatus).filter((arr) => arr.length > 0); expect(filledColumns.length).toBe(0); }); it('reload-btn вызывает listDeals второй раз', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValue({ deals: [], total: 0, limit: 500, offset: 0, }); const wrapper = mountKanbanView(); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); await wrapper.find('[data-testid="reload-btn"]').trigger('click'); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(2); }); });