Files
portal/app/tests/Frontend/KanbanView.spec.ts
T

242 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});