2026-05-15 08:51:21 +03:00
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
2026-05-17 06:12:52 +03:00
|
|
|
|
import { mount, flushPromises } from '@vue/test-utils';
|
2026-05-08 17:56:59 +03:00
|
|
|
|
import { createVuetify } from 'vuetify';
|
2026-05-09 06:43:21 +03:00
|
|
|
|
import { createPinia, setActivePinia } from 'pinia';
|
2026-05-08 17:56:59 +03:00
|
|
|
|
import KanbanView from '../../resources/js/views/KanbanView.vue';
|
2026-05-15 08:51:21 +03:00
|
|
|
|
import { useAuthStore } from '../../resources/js/stores/auth';
|
|
|
|
|
|
import * as dealsApi from '../../resources/js/api/deals';
|
2026-05-08 17:56:59 +03:00
|
|
|
|
import { LEAD_STATUSES } from '../../resources/js/composables/leadStatuses';
|
2026-05-17 06:12:52 +03:00
|
|
|
|
import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals';
|
2026-05-08 17:56:59 +03:00
|
|
|
|
|
|
|
|
|
|
describe('KanbanView.vue', () => {
|
2026-05-08 18:29:11 +03:00
|
|
|
|
// KanbanView содержит DealDetailDrawer (v-navigation-drawer), который требует
|
|
|
|
|
|
// injected layout от v-app — оборачиваем в v-app для теста.
|
|
|
|
|
|
// KanbanView содержит DealDetailDrawer (v-navigation-drawer) — stub'им,
|
|
|
|
|
|
// т.к. layout-injection недоступна в Vitest. Drawer тестируется отдельно.
|
2026-05-09 06:43:21 +03:00
|
|
|
|
const factory = () => {
|
|
|
|
|
|
setActivePinia(createPinia());
|
|
|
|
|
|
return mount(KanbanView, {
|
2026-05-08 18:29:11 +03:00
|
|
|
|
global: {
|
|
|
|
|
|
plugins: [createVuetify()],
|
2026-05-09 05:33:21 +03:00
|
|
|
|
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
2026-05-08 18:29:11 +03:00
|
|
|
|
},
|
2026-05-08 17:56:59 +03:00
|
|
|
|
});
|
2026-05-09 06:43:21 +03:00
|
|
|
|
};
|
2026-05-08 17:56:59 +03:00
|
|
|
|
|
|
|
|
|
|
it('монтируется и содержит заголовок «Канбан»', () => {
|
|
|
|
|
|
const wrapper = factory();
|
|
|
|
|
|
expect(wrapper.find('h1').text()).toBe('Канбан');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-17 18:19:39 +03:00
|
|
|
|
it('рендерит ровно 5 KanbanColumn (по числу lead_statuses)', () => {
|
2026-05-08 17:56:59 +03:00
|
|
|
|
const wrapper = factory();
|
|
|
|
|
|
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
|
|
|
|
|
expect(cols).toHaveLength(LEAD_STATUSES.length);
|
2026-05-17 18:19:39 +03:00
|
|
|
|
expect(cols).toHaveLength(5);
|
2026-05-08 17:56:59 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-05-17 18:19:39 +03:00
|
|
|
|
expect(text).toContain('5');
|
2026-05-08 17:56:59 +03:00
|
|
|
|
expect(text).toContain('статусов');
|
|
|
|
|
|
expect(text).toContain('сделок');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('содержит кнопку «Новая сделка»', () => {
|
|
|
|
|
|
const wrapper = factory();
|
|
|
|
|
|
expect(wrapper.text()).toContain('Новая сделка');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-08 18:08:16 +03:00
|
|
|
|
it('содержит подсказку про перетаскивание (DnD активен)', () => {
|
2026-05-08 17:56:59 +03:00
|
|
|
|
const wrapper = factory();
|
2026-05-08 18:08:16 +03:00
|
|
|
|
expect(wrapper.text()).toMatch(/[Пп]еретаскивание/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-23 03:47:19 +03:00
|
|
|
|
// Кнопка «Новая сделка» убрана из Канбана (задача 22.06 — создание сделок не с доски).
|
|
|
|
|
|
// Тест клика по `new-deal-btn` удалён вместе с кнопкой.
|
2026-05-09 05:33:21 +03:00
|
|
|
|
|
|
|
|
|
|
it('onDealCreated кладёт сделку в правильную колонку по statusSlug', async () => {
|
|
|
|
|
|
const wrapper = factory();
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
dealsByStatus: Record<string, Array<{ id: number; statusSlug: string }>>;
|
|
|
|
|
|
onDealCreated: (deal: Record<string, unknown>) => 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-08 18:08:16 +03:00
|
|
|
|
it('обновляет statusSlug сделки при drop в новую колонку (event.added)', async () => {
|
|
|
|
|
|
const wrapper = factory();
|
2026-05-17 06:12:52 +03:00
|
|
|
|
// Засеваем dealsByStatus фикстурой MOCK_DEALS (init теперь пустой).
|
|
|
|
|
|
const vm = wrapper.vm as unknown as { dealsByStatus: Record<string, MockDeal[]> };
|
|
|
|
|
|
for (const deal of MOCK_DEALS) {
|
|
|
|
|
|
if (vm.dealsByStatus[deal.statusSlug]) {
|
|
|
|
|
|
vm.dealsByStatus[deal.statusSlug].push({ ...deal });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
|
2026-05-08 18:08:16 +03:00
|
|
|
|
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
|
|
|
|
|
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
|
|
|
|
|
|
const newCol = cols[0]; // new — sortOrder=1
|
2026-05-17 18:19:39 +03:00
|
|
|
|
const wonCol = cols.find((c) => c.props('status').slug === 'won')!;
|
2026-05-08 18:08:16 +03:00
|
|
|
|
const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0];
|
|
|
|
|
|
|
|
|
|
|
|
// Эмуляция события vuedraggable@change → KanbanView.onColumnChange.
|
2026-05-17 18:19:39 +03:00
|
|
|
|
await wonCol.vm.$emit('change', {
|
2026-05-08 18:08:16 +03:00
|
|
|
|
added: { element: dealToMove, newIndex: 0 },
|
|
|
|
|
|
});
|
|
|
|
|
|
await wrapper.vm.$nextTick();
|
|
|
|
|
|
|
2026-05-17 18:19:39 +03:00
|
|
|
|
// statusSlug сделки должен переключиться на 'won'.
|
|
|
|
|
|
expect(dealToMove.statusSlug).toBe('won');
|
2026-05-08 17:56:59 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-05-15 08:51:21 +03:00
|
|
|
|
|
2026-05-17 06:12:52 +03:00
|
|
|
|
// I3 regression: API reject → dealsByStatus пустые + fetchError=true (нет mock-fallback)
|
2026-05-17 06:18:26 +03:00
|
|
|
|
// Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals.
|
2026-05-17 06:12:52 +03:00
|
|
|
|
describe('KanbanView I3 regression', () => {
|
|
|
|
|
|
it('loadDeals reject оставляет dealsByStatus пустыми и выставляет fetchError', async () => {
|
|
|
|
|
|
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
|
|
|
|
|
setActivePinia(createPinia());
|
2026-05-17 06:18:26 +03:00
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as never;
|
2026-05-17 06:12:52 +03:00
|
|
|
|
const wrapper = mount(KanbanView, {
|
|
|
|
|
|
global: {
|
|
|
|
|
|
plugins: [createVuetify()],
|
|
|
|
|
|
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-05-17 06:18:26 +03:00
|
|
|
|
await flushPromises();
|
2026-05-17 06:12:52 +03:00
|
|
|
|
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
dealsByStatus: Record<string, MockDeal[]>;
|
|
|
|
|
|
fetchError: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
expect(vm.fetchError).toBe(true);
|
|
|
|
|
|
// Все колонки пусты — нет mock-fallback
|
|
|
|
|
|
const allDeals = Object.values(vm.dealsByStatus).flat();
|
|
|
|
|
|
expect(allDeals.length).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-15 08:51:21 +03:00
|
|
|
|
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,
|
2026-05-17 18:19:39 +03:00
|
|
|
|
status: 'in_progress',
|
2026-05-15 08:51:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
2026-06-17 05:17:12 +03:00
|
|
|
|
const deal = {
|
|
|
|
|
|
id: 42,
|
|
|
|
|
|
statusSlug: 'new' as const,
|
|
|
|
|
|
name: 'X',
|
|
|
|
|
|
phone: '+79161234567',
|
|
|
|
|
|
project: 'p',
|
|
|
|
|
|
manager: { name: 'M', initials: 'M' },
|
|
|
|
|
|
cost: 100,
|
|
|
|
|
|
receivedMinutesAgo: 5,
|
|
|
|
|
|
};
|
2026-05-15 08:51:21 +03:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2026-05-17 18:19:39 +03:00
|
|
|
|
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
2026-05-15 08:51:21 +03:00
|
|
|
|
|
|
|
|
|
|
expect(transitionSpy).toHaveBeenCalledWith({
|
|
|
|
|
|
tenant_id: 7,
|
|
|
|
|
|
ids: [42],
|
2026-05-17 18:19:39 +03:00
|
|
|
|
status: 'in_progress',
|
2026-05-15 08:51:21 +03:00
|
|
|
|
});
|
2026-05-17 18:19:39 +03:00
|
|
|
|
expect(deal.statusSlug).toBe('in_progress');
|
2026-05-15 08:51:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
2026-06-17 05:17:12 +03:00
|
|
|
|
const deal = {
|
|
|
|
|
|
id: 43,
|
|
|
|
|
|
statusSlug: 'new' as const,
|
|
|
|
|
|
name: 'Y',
|
|
|
|
|
|
phone: '+79161234567',
|
|
|
|
|
|
project: 'p',
|
|
|
|
|
|
manager: { name: 'M', initials: 'M' },
|
|
|
|
|
|
cost: 100,
|
|
|
|
|
|
receivedMinutesAgo: 5,
|
|
|
|
|
|
};
|
2026-05-15 08:58:11 +03:00
|
|
|
|
// Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange.
|
2026-05-15 08:51:21 +03:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2026-05-15 08:58:11 +03:00
|
|
|
|
const vm = wrapper.vm as any;
|
2026-05-17 18:19:39 +03:00
|
|
|
|
if (!vm.dealsByStatus.in_progress) vm.dealsByStatus.in_progress = [];
|
|
|
|
|
|
vm.dealsByStatus.in_progress.push(deal);
|
2026-05-15 08:58:11 +03:00
|
|
|
|
|
2026-05-17 18:19:39 +03:00
|
|
|
|
await vm.onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
2026-05-15 08:51:21 +03:00
|
|
|
|
|
2026-05-15 08:58:11 +03:00
|
|
|
|
// statusSlug rolled back
|
2026-05-15 08:51:21 +03:00
|
|
|
|
expect(deal.statusSlug).toBe('new');
|
2026-05-15 08:58:11 +03:00
|
|
|
|
// Card removed from target column (array-revert branch coverage)
|
2026-05-17 18:19:39 +03:00
|
|
|
|
expect(vm.dealsByStatus.in_progress.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
|
2026-05-15 08:58:11 +03:00
|
|
|
|
// 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('Не удалось');
|
2026-05-15 08:51:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
|
|
|
|
|
|
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
|
2026-06-17 05:17:12 +03:00
|
|
|
|
updated: 1,
|
|
|
|
|
|
requested: 1,
|
|
|
|
|
|
status: 'in_progress',
|
2026-05-15 08:51:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
2026-06-17 05:17:12 +03:00
|
|
|
|
const deal = {
|
|
|
|
|
|
id: 44,
|
|
|
|
|
|
statusSlug: 'new' as const,
|
|
|
|
|
|
name: 'Z',
|
|
|
|
|
|
phone: '+79161234567',
|
|
|
|
|
|
project: 'p',
|
|
|
|
|
|
manager: { name: 'M', initials: 'M' },
|
|
|
|
|
|
cost: 100,
|
|
|
|
|
|
receivedMinutesAgo: 5,
|
|
|
|
|
|
};
|
2026-05-15 08:51:21 +03:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2026-05-17 18:19:39 +03:00
|
|
|
|
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
2026-05-15 08:51:21 +03:00
|
|
|
|
|
|
|
|
|
|
// Без auth — только optimistic local change, API не зовётся
|
|
|
|
|
|
expect(transitionSpy).not.toHaveBeenCalled();
|
2026-05-17 18:19:39 +03:00
|
|
|
|
expect(deal.statusSlug).toBe('in_progress');
|
2026-05-15 08:51:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|