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(), exportDeals: vi.fn(), exportDealsXlsx: vi.fn(), bulkDeleteDeals: vi.fn(), bulkRestoreDeals: 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(), ...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, NewDealDialog: 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 не вызывается, fallback на MOCK_DEALS', async () => { setupAuth(null); const wrapper = await mountDealsView(); await flushPromises(); expect(dealsApi.listDeals).not.toHaveBeenCalled(); // MOCK_DEALS содержит 12 элементов — fallback виден. const vm = wrapper.vm as unknown as { dealsState: { id: number }[] }; expect(vm.dealsState.length).toBeGreaterThan(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: 'paid' }), makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }), ], total: 2, limit: 200, offset: 0, }); const wrapper = await mountDealsView(); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 200 })); 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 виден, MOCK_DEALS остаётся как fallback', 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).toBeGreaterThan(0); // Alert виден. expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true); }); it('toggleTrashMode переключает trashMode + listDeals вызывается с onlyDeleted=true', async () => { setupAuth(1); // Начальный fetch (нормальный режим) vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 600 })], total: 1, limit: 200, offset: 0, }); // После toggle в trash vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 700, contact_name: 'Удалённый' })], total: 1, limit: 200, offset: 0, }); const wrapper = await mountDealsView(); await flushPromises(); expect(dealsApi.listDeals).toHaveBeenCalledTimes(1); expect(dealsApi.listDeals).toHaveBeenLastCalledWith( expect.objectContaining({ tenantId: 1, onlyDeleted: false }), ); const vm = wrapper.vm as unknown as { trashMode: boolean; toggleTrashMode: () => void; dealsState: { id: number }[]; }; vm.toggleTrashMode(); await flushPromises(); expect(vm.trashMode).toBe(true); expect(dealsApi.listDeals).toHaveBeenCalledTimes(2); expect(dealsApi.listDeals).toHaveBeenLastCalledWith( expect.objectContaining({ tenantId: 1, onlyDeleted: true }), ); expect(vm.dealsState.find((d) => d.id === 700)).toBeDefined(); }); it('applyBulkRestoreFromTrash восстанавливает + убирает из dealsState', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 800 }), makeApiDeal({ id: 801 })], total: 2, limit: 200, offset: 0, }); vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({ restored: 2, requested: 2, }); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkRestoreFromTrash: () => Promise; dealsState: { id: number }[]; deleteToastText: string; }; vm.selected = [800, 801]; await flushPromises(); await vm.applyBulkRestoreFromTrash(); await flushPromises(); expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [800, 801] }); // Восстановленные убраны из текущего trash-списка. expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined(); expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined(); expect(vm.deleteToastText).toContain('Восстановлено 2'); }); it('reload-btn повторно вызывает listDeals', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValue({ deals: [makeApiDeal({ id: 400 })], total: 1, limit: 200, 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: 200, offset: 0, }); vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({ updated: 2, requested: 2, status: 'paid', }); 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('paid'); await flushPromises(); // Optimistic local-update применился до завершения API-вызова. expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid'); expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid'); expect(dealsApi.transitionDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [500, 501], status: 'paid', }); expect(vm.statusToastOpen).toBe(true); expect(vm.statusToastText).toContain('Обновлено 2'); }); it('applyBulkStatus БЕЗ tenant_id — только локальный update, transitionDeals НЕ вызывается', async () => { setupAuth(null); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkStatus: (slug: string) => Promise; dealsState: { id: number; statusSlug: string }[]; }; vm.selected = [1]; await flushPromises(); await vm.applyBulkStatus('paid'); await flushPromises(); expect(dealsApi.transitionDeals).not.toHaveBeenCalled(); expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('paid'); }); it('applyBulkExport(xlsx) с tenant_id вызывает exportDealsXlsx и триггерит download', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 700 })], total: 1, limit: 200, offset: 0, }); const fakeBlob = new Blob(['fake xlsx'], { type: 'application/octet-stream' }); vi.mocked(dealsApi.exportDealsXlsx).mockResolvedValueOnce(fakeBlob); const createUrlSpy = vi.fn(() => 'blob:xlsx'); const revokeSpy = vi.fn(); Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true }); Object.defineProperty(URL, 'revokeObjectURL', { value: revokeSpy, configurable: true }); const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkExport: (format?: string) => Promise; exportToastText: string; }; vm.selected = [700]; await flushPromises(); await vm.applyBulkExport(); // default = xlsx await flushPromises(); expect(dealsApi.exportDealsXlsx).toHaveBeenCalledWith({ tenant_id: 1, ids: [700], }); expect(dealsApi.exportDeals).not.toHaveBeenCalled(); expect(createUrlSpy).toHaveBeenCalledTimes(1); expect(clickSpy).toHaveBeenCalledTimes(1); expect(vm.exportToastText).toContain('XLSX'); clickSpy.mockRestore(); }); it('applyBulkExport(csv) с tenant_id вызывает exportDeals (CSV branch)', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 701 })], total: 1, limit: 200, offset: 0, }); vi.mocked(dealsApi.exportDeals).mockResolvedValueOnce('id;...'); Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true }); const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkExport: (format?: string) => Promise; exportToastText: string; }; vm.selected = [701]; await flushPromises(); await vm.applyBulkExport('csv'); await flushPromises(); expect(dealsApi.exportDeals).toHaveBeenCalledTimes(1); expect(dealsApi.exportDealsXlsx).not.toHaveBeenCalled(); expect(vm.exportToastText).toContain('CSV'); clickSpy.mockRestore(); }); it('applyBulkExport(xlsx) reject → fallback на local CSV', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 702 })], total: 1, limit: 200, offset: 0, }); vi.mocked(dealsApi.exportDealsXlsx).mockRejectedValueOnce(new Error('500')); Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true }); const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkExport: () => Promise; exportToastText: string; }; vm.selected = [702]; await flushPromises(); await vm.applyBulkExport(); await flushPromises(); expect(vm.exportToastText).toContain('Backend недоступен'); // local CSV всё равно стриггерил download expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); it('applyBulkDelete с tenant_id вызывает bulkDeleteDeals + optimistic local-removal', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 800, status: 'new' }), makeApiDeal({ id: 801, status: 'new' })], total: 2, limit: 200, offset: 0, }); vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 2, requested: 2, }); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkDelete: () => Promise; dealsState: { id: number }[]; deleteToastOpen: boolean; deleteToastText: string; }; vm.selected = [800, 801]; await flushPromises(); await vm.applyBulkDelete(); await flushPromises(); // Optimistic — обе сделки убраны из state. expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined(); expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined(); expect(dealsApi.bulkDeleteDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [800, 801], }); expect(vm.deleteToastOpen).toBe(true); expect(vm.deleteToastText).toContain('Удалено 2'); }); it('applyBulkDelete без tenant_id — только локально, bulkDeleteDeals НЕ вызывается', async () => { setupAuth(null); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkDelete: () => Promise; dealsState: { id: number }[]; }; const before = vm.dealsState.length; vm.selected = [1, 2]; await flushPromises(); await vm.applyBulkDelete(); await flushPromises(); expect(dealsApi.bulkDeleteDeals).not.toHaveBeenCalled(); expect(vm.dealsState.length).toBe(before - 2); }); it('applyBulkDelete reject → warning toast, локальный update остаётся (не откатываем)', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 900 })], total: 1, limit: 200, offset: 0, }); vi.mocked(dealsApi.bulkDeleteDeals).mockRejectedValueOnce(new Error('500')); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkDelete: () => Promise; dealsState: { id: number }[]; deleteToastText: string; }; vm.selected = [900]; await flushPromises(); await vm.applyBulkDelete(); await flushPromises(); expect(vm.dealsState.find((d) => d.id === 900)).toBeUndefined(); // optimistic expect(vm.deleteToastText).toContain('Не удалось'); }); it('bulk-delete + undo восстанавливает сделки + вызывает bulkRestoreDeals', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 1000, contact_name: 'A' }), makeApiDeal({ id: 1001, contact_name: 'B' })], total: 2, limit: 200, offset: 0, }); vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 2, requested: 2, }); vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({ restored: 2, requested: 2, }); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkDelete: () => Promise; undoBulkDelete: () => Promise; dealsState: { id: number }[]; lastDeletedSnapshot: { id: number }[]; deleteToastText: string; }; // Удаляем vm.selected = [1000, 1001]; await flushPromises(); await vm.applyBulkDelete(); await flushPromises(); expect(vm.dealsState.find((d) => d.id === 1000)).toBeUndefined(); expect(vm.lastDeletedSnapshot).toHaveLength(2); // Undo await vm.undoBulkDelete(); await flushPromises(); expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [1000, 1001] }); expect(vm.dealsState.find((d) => d.id === 1000)).toBeDefined(); expect(vm.dealsState.find((d) => d.id === 1001)).toBeDefined(); expect(vm.lastDeletedSnapshot).toHaveLength(0); // cleared after undo expect(vm.deleteToastText).toContain('Восстановлено 2'); }); it('undoBulkDelete без tenant_id — только локально, bulkRestoreDeals НЕ вызывается', async () => { setupAuth(null); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkDelete: () => Promise; undoBulkDelete: () => Promise; dealsState: { id: number }[]; lastDeletedSnapshot: { id: number }[]; }; const sample = vm.dealsState[0]; vm.selected = [sample.id]; await flushPromises(); await vm.applyBulkDelete(); await flushPromises(); expect(vm.dealsState.find((d) => d.id === sample.id)).toBeUndefined(); await vm.undoBulkDelete(); await flushPromises(); expect(dealsApi.bulkRestoreDeals).not.toHaveBeenCalled(); expect(vm.dealsState.find((d) => d.id === sample.id)).toBeDefined(); }); it('undoBulkDelete reject → warning toast, локальное восстановление остаётся', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 1100 })], total: 1, limit: 200, offset: 0, }); vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 1, requested: 1 }); vi.mocked(dealsApi.bulkRestoreDeals).mockRejectedValueOnce(new Error('500')); const wrapper = await mountDealsView(); await flushPromises(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkDelete: () => Promise; undoBulkDelete: () => Promise; dealsState: { id: number }[]; deleteToastText: string; }; vm.selected = [1100]; await flushPromises(); await vm.applyBulkDelete(); await flushPromises(); await vm.undoBulkDelete(); await flushPromises(); expect(vm.dealsState.find((d) => d.id === 1100)).toBeDefined(); // optimistic expect(vm.deleteToastText).toContain('Не удалось восстановить'); }); it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => { setupAuth(1); vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({ deals: [makeApiDeal({ id: 600, status: 'new' })], total: 1, limit: 200, 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('paid'); await flushPromises(); expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid'); 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: 'paid' }), makeApiDeal({ id: 302, status: 'paid' }), ], 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.paid.map((d) => d.id).sort()).toEqual([301, 302]); expect(vm.totalDeals).toBe(3); expect(vm.fetchError).toBe(false); }); it('listDeals reject → fetchError=true, MOCK_DEALS остаётся в колонках', 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); // Хотя бы одна колонка с mock-сделками заполнена (изначальный state). const filledColumns = Object.values(vm.dealsByStatus).filter((arr) => arr.length > 0); expect(filledColumns.length).toBeGreaterThan(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); }); });