242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
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(/[Пп]еретаскивание/);
|
||
});
|
||
|
||
it('кнопка «Новая сделка» открывает NewDealDialog', async () => {
|
||
const wrapper = factory();
|
||
const vm = wrapper.vm as unknown as { newDealOpen: boolean };
|
||
expect(vm.newDealOpen).toBe(false);
|
||
await wrapper.find('[data-testid="new-deal-btn"]').trigger('click');
|
||
await wrapper.vm.$nextTick();
|
||
expect(vm.newDealOpen).toBe(true);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('обновляет statusSlug сделки при drop в новую колонку (event.added)', async () => {
|
||
const wrapper = factory();
|
||
// Засеваем 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();
|
||
|
||
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<string, MockDeal[]>;
|
||
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');
|
||
});
|
||
});
|