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

376 lines
17 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, test, expect, vi, afterEach } 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 { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import * as dealsApi from '../../resources/js/api/deals';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
// Smoke-тесты DealsView с mock-данными.
const mountDeals = async () => {
setActivePinia(createPinia());
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push('/deals');
await router.isReady();
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
// injected layout от v-app — оборачиваем компонент в v-app для теста.
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
// layout-injection от v-app. В Vitest vite-plugin-vuetify auto-import не
// работает, layout-context недоступен. Stub'им сам Drawer (тестируется
// отдельно в DealDetailDrawer.spec.ts).
return mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
};
/** Audit C8/F3: монтирует DealsView по произвольному пути (с query-параметрами). */
const mountDealsViewAt = async (path: string) => {
setActivePinia(createPinia());
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push(path);
await router.isReady();
return mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
};
describe('DealsView.vue', () => {
it('монтируется и содержит заголовок «Сделки»', async () => {
const wrapper = await mountDeals();
expect(wrapper.find('h1').text()).toBe('Сделки');
});
it('содержит page-stats с числами всего/в работе/ждут оплату', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
expect(text).toContain('новых лида с утра');
expect(text).toContain('всего');
expect(text).toContain('в работе');
expect(text).toContain('ждут оплату');
});
it('содержит ровно 5 chiprow-tabs', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
['Все', 'Активные', 'Ждут оплату', 'Закрытые', 'Невалидные'].forEach((label) => expect(text).toContain(label));
});
it('по умолчанию активен таб «Активные», показывает только active-сделки', async () => {
const wrapper = await mountDeals();
await flushPromises();
const activeStatuses = ['new', 'viewed', 'worked', 'negotiations', 'hot'];
const expectedCount = MOCK_DEALS.filter((d) => activeStatuses.includes(d.statusSlug)).length;
const rows = wrapper.findAll('tbody tr');
expect(rows).toHaveLength(expectedCount);
});
it('содержит кнопки Экспорт и Новая сделка', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
expect(text).toContain('Экспорт');
expect(text).toContain('Новая сделка');
});
it('таблица содержит колонки Лид/Статус/Проект/Менеджер/Стоимость/Время', async () => {
const wrapper = await mountDeals();
const headers = wrapper.findAll('thead th').map((h) => h.text());
['Лид', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Время'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('форматирует стоимость как «N ₽» с разделителем тысяч', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
// Intl.NumberFormat('ru-RU') использует non-breaking space (U+00A0) или
// narrow nbsp (U+202F) как разделитель тысяч, не ASCII-пробел. Явные
// \u-escape'ы — иначе ESLint ругается no-irregular-whitespace.
expect(text).toMatch(/2\s+400\s*₽/);
});
it('форматирует «время с момента» как «N мин назад» для свежих сделок', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
expect(text).toContain('7 мин назад');
});
it('bulk-bar скрыт когда selected пустой; виден когда selected не пустой', async () => {
const wrapper = await mountDeals();
await flushPromises();
// По умолчанию ничего не выбрано
expect(wrapper.find('[data-testid="bulk-bar"]').exists()).toBe(false);
// Симулируем выбор через v-model: selected
const vm = wrapper.vm as unknown as { selected: number[] };
vm.selected = [1, 2];
await flushPromises();
const bar = wrapper.find('[data-testid="bulk-bar"]');
expect(bar.exists()).toBe(true);
expect(bar.text()).toContain('Выбрано');
expect(bar.text()).toContain('2');
});
it('bulk-status: применение нового статуса меняет statusSlug у выбранных сделок и закрывает меню', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
dealsState: Array<{ id: number; statusSlug: string }>;
applyBulkStatus: (slug: string) => void;
};
vm.selected = [1, 2];
await flushPromises();
// До применения — id=1 'new', id=2 'worked' (из MOCK_DEALS)
const before1 = vm.dealsState.find((d) => d.id === 1)!.statusSlug;
expect(before1).toBe('new');
vm.applyBulkStatus('paid');
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 1)!.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 2)!.statusSlug).toBe('paid');
});
it('bulk-delete: confirm удаляет выбранные сделки и сбрасывает selected', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
dealsState: Array<{ id: number }>;
applyBulkDelete: () => void;
};
const before = vm.dealsState.length;
vm.selected = [1, 3];
await flushPromises();
vm.applyBulkDelete();
await flushPromises();
expect(vm.dealsState.length).toBe(before - 2);
expect(vm.dealsState.find((d) => d.id === 1)).toBeUndefined();
expect(vm.dealsState.find((d) => d.id === 3)).toBeUndefined();
expect(vm.selected).toEqual([]);
});
it('bulk-export: показывает toast с количеством выбранных сделок + триггерит CSV-download', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: () => void;
exportToastOpen: boolean;
exportToastText: string;
};
// Шпион на createObjectURL — в jsdom он бывает не определён, заменим.
const createUrlSpy = vi.fn(() => 'blob:mock');
const revokeUrlSpy = vi.fn();
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeUrlSpy, configurable: true });
// Подменяем click() на якоре чтобы не словить navigation в jsdom.
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
vm.selected = [1, 2, 3, 4];
await flushPromises();
vm.applyBulkExport();
expect(createUrlSpy).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(vm.exportToastOpen).toBe(true);
expect(vm.exportToastText).toContain('4');
expect(vm.exportToastText).toContain('CSV');
clickSpy.mockRestore();
});
it('bulk-export: пустой selected → toast «Нет выбранных» без CSV', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: () => void;
exportToastOpen: boolean;
exportToastText: string;
};
const createUrlSpy = vi.fn(() => 'blob:mock');
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
vm.selected = [];
vm.applyBulkExport();
expect(createUrlSpy).not.toHaveBeenCalled();
expect(vm.exportToastText).toContain('Нет выбранных');
});
it('кнопка «Новая сделка» открывает NewDealDialog (newDealOpen=true)', async () => {
const wrapper = await mountDeals();
await flushPromises();
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 flushPromises();
expect(vm.newDealOpen).toBe(true);
});
it('onDealCreated добавляет новую сделку в начало dealsState', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
dealsState: Array<{ id: number; name: string; statusSlug: string }>;
onDealCreated: (deal: Record<string, unknown>) => void;
};
const before = vm.dealsState.length;
// Передаём полную форму deal — table-cell ожидает manager.name/phone/cost.
vm.onDealCreated({
id: 999,
name: 'Новый клиент',
phone: '+7 (999) 000-00-00',
statusSlug: 'new',
project: 'Окна Москва',
manager: { initials: 'Н', name: 'Новый М.' },
cost: 1000,
receivedMinutesAgo: 0,
});
await flushPromises();
expect(vm.dealsState.length).toBe(before + 1);
expect(vm.dealsState[0].id).toBe(999);
expect(vm.dealsState[0].name).toBe('Новый клиент');
});
it('smart-filter projects: оставляет только сделки выбранного проекта', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as { activeTab: string; filterProjects: string[] };
vm.activeTab = 'all';
vm.filterProjects = ['Окна Москва'];
await flushPromises();
const rows = wrapper.findAll('tbody tr');
// Минимум одна строка, и все содержат «Окна Москва»
expect(rows.length).toBeGreaterThan(0);
rows.forEach((row) => expect(row.text()).toContain('Окна Москва'));
});
it('smart-filter managers: оставляет только сделки выбранного менеджера', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as { activeTab: string; filterManagers: string[] };
vm.activeTab = 'all';
vm.filterManagers = ['Иван П.'];
await flushPromises();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBeGreaterThan(0);
rows.forEach((row) => expect(row.text()).toContain('Иван П.'));
});
it('clearFilters сбрасывает projects+managers фильтры, кнопка появляется по условию', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
filterProjects: string[];
filterManagers: string[];
clearFilters: () => void;
};
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false);
vm.filterProjects = ['Окна Москва'];
await flushPromises();
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
vm.clearFilters();
await flushPromises();
expect(vm.filterProjects).toEqual([]);
expect(vm.filterManagers).toEqual([]);
});
it('bulk-clear: иконка ✕ сбрасывает selected', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as { selected: number[] };
vm.selected = [1, 2];
await flushPromises();
const clearBtn = wrapper.find('[data-testid="bulk-clear-btn"]');
expect(clearBtn.exists()).toBe(true);
await clearBtn.trigger('click');
await flushPromises();
expect(vm.selected).toEqual([]);
});
// Audit C8/F3: deep-link /deals?openId=
it('route.query.openId открывает drawer соответствующей сделки', async () => {
const openId = MOCK_DEALS[0].id;
const wrapper = await mountDealsViewAt(`/deals?openId=${openId}`);
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
expect(vm.drawerOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(openId);
});
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
expect(vm.drawerOpen).toBe(false);
});
it('навигация на /deals?openId= в смонтированном view открывает drawer (watch)', async () => {
const openId = MOCK_DEALS[0].id;
const wrapper = await mountDealsViewAt('/deals');
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
expect(vm.drawerOpen).toBe(false);
await wrapper.vm.$router.push(`/deals?openId=${openId}`);
await flushPromises();
expect(vm.drawerOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(openId);
});
});
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
const wrapper = await mountDeals();
await flushPromises();
// Установить auth.user с tenant_id чтобы exportDealIds пошёл в backend
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
// activeTab по умолчанию 'active' — установить 'all' чтобы filteredDeals === dealsState
const vm = wrapper.vm as unknown as {
activeTab: string;
dealsState: Array<{ id: number }>;
exportAllFiltered: () => Promise<void>;
exportToastOpen: boolean;
};
vm.activeTab = 'all';
await flushPromises();
await vm.exportAllFiltered();
expect(xlsxSpy).toHaveBeenCalledTimes(1);
const callArg = xlsxSpy.mock.calls[0][0];
expect(callArg.ids).toEqual(vm.dealsState.map((d) => d.id));
expect(vm.exportToastOpen).toBe(true);
});
test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
const wrapper = await mountDeals();
await flushPromises();
const vm = wrapper.vm as unknown as {
activeTab: string;
dealsState: Array<{ id: number }>;
exportAllFiltered: () => Promise<void>;
exportToastOpen: boolean;
exportToastText: string;
};
// Очистить список и поставить tab='all' чтобы filteredDeals тоже пустой
vm.activeTab = 'all';
vm.dealsState.splice(0, vm.dealsState.length);
await flushPromises();
await vm.exportAllFiltered();
expect(xlsxSpy).not.toHaveBeenCalled();
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
});
afterEach(() => vi.restoreAllMocks());