c5ca3c4387
Промт называл один тест, который печатал сетевую ошибку. Сторож нашёл 93 теста в 14 файлах: остальные 92 глушились чужими catch внутри рабочего кода и были невидимы вообще. Запрета на выход наружу в тестах интерфейса не было ни одного за всю историю — тот же класс, что закрыли 01.08 на PHP. Сторож в app/tests/Frontend/setup.ts делает две вещи, и обе нужны: обрывает запрос и запоминает адрес, роняя тест в afterEach. Без второй половины код, который глушит отказ своим catch, снова всё прячет. Там же поимённо заглушены четыре фоновые точки оболочки — счётчик сделок, состояние автоподбора, статус баланса, отметка о посещении. Их не спрашивает ни один тест разметки, а отказ у всех четырёх и так проглатывается. Остальные семь файлов глушат свои вызовы у себя; общий ответ статусов воронки лежит в tests/Frontend/support/fixtures.ts. Заглушки повторяют то состояние, которое тесты видели при отказе сети, поэтому ни одна проверка не переписывалась. track.spec.ts снимает общую заглушку через vi.unmock — он проверяет сам заглушенный код. Приёмка: сторож проверен красным трижды — обычный запрос, запрос с проглоченной ошибкой, fetch. Полный набор интерфейса 233 файла / 1750 тестов / 3 пропущено / 0 падений / 0 ошибок. Линтер интерфейса 0, проверка типов 5 — те же унаследованные, что были до смены. Рабочий код не тронут. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
10 KiB
TypeScript
213 lines
10 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
|
|
// Экран тянет статусы воронки с сервера. Без заглушки тест физически ходил в сеть на
|
|
// /api/lead-statuses и всё равно был зелёным: хранилище глушит отказ своим catch и
|
|
// остаётся на снимке из кода. Ловит сторож сети в tests/Frontend/setup.ts.
|
|
vi.mock('../../resources/js/api/leadStatuses', async () => {
|
|
const { otvetStatusovVoronki } = await import('./support/fixtures');
|
|
return { listLeadStatuses: async () => otvetStatusovVoronki() };
|
|
});
|
|
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 * as dealsApi from '../../resources/js/api/deals';
|
|
import { useAuthStore } from '../../resources/js/stores/auth';
|
|
import type { AuthUser } from '../../resources/js/api/auth';
|
|
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
|
|
|
function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
|
|
return {
|
|
id,
|
|
tenant_id: 42,
|
|
project_id: 1,
|
|
project_name: 'Окна',
|
|
phone: `+7 916 000-00-0${id}`,
|
|
contact_name: null,
|
|
status: 'new',
|
|
manager_id: null,
|
|
manager_name: null,
|
|
manager_initials: null,
|
|
received_at: '2026-05-15T09:00:00+00:00',
|
|
comment: null,
|
|
city: null,
|
|
project_signal_type: 'call',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) {
|
|
setActivePinia(createPinia());
|
|
const auth = useAuthStore();
|
|
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
|
|
const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
|
|
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([{ id: 1, name: 'Окна', tag: null, type: 'supplier' }]);
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [{ path: '/deals', component: DealsView }],
|
|
});
|
|
await router.push('/deals');
|
|
await router.isReady();
|
|
const wrapper = mount(DealsView, {
|
|
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
|
|
});
|
|
await flushPromises();
|
|
// Reset call history so subsequent vi.spyOn calls in tests start from count=0.
|
|
dealsSpy.mockClear();
|
|
return wrapper;
|
|
}
|
|
|
|
describe('DealsView.vue — реестр лидов', () => {
|
|
it('заголовок «Сделки»', async () => {
|
|
expect((await mountDeals()).find('h1').text()).toBe('Сделки');
|
|
});
|
|
|
|
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
|
|
const w = await mountDeals();
|
|
const panel = w.find('.export-panel');
|
|
expect(panel.exists()).toBe(true);
|
|
// 2 поля даты: RuDateField построен на v-menu, поэтому data-testid не доходит
|
|
// до DOM-элемента — проверяем по input'ам активаторов (v-text-field) внутри панели.
|
|
expect(panel.findAll('input').length).toBeGreaterThanOrEqual(2);
|
|
// Кнопки — v-btn, data-testid доходит до корня button.
|
|
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
|
|
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('селектор «Показывать по» с вариантами 10/20/50', async () => {
|
|
const w = await mountDeals();
|
|
const toggle = w.find('[data-testid="perpage-toggle"]');
|
|
expect(toggle.exists()).toBe(true);
|
|
['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n));
|
|
});
|
|
|
|
it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
|
|
const w = await mountDeals();
|
|
// «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` —
|
|
// редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно
|
|
// КНОПКИ создания сделки: ручное создание убрано в реестре лидов.
|
|
const buttons = w.findAll('button');
|
|
expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false);
|
|
expect(w.text()).not.toContain('Корзина');
|
|
});
|
|
|
|
it('загружает сделки в dealsState через API', async () => {
|
|
const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3);
|
|
const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number };
|
|
expect(vm.dealsState.length).toBe(3);
|
|
expect(vm.total).toBe(3);
|
|
});
|
|
|
|
it('openPanel выбирает сделку, повторный клик закрывает', async () => {
|
|
const w = await mountDeals();
|
|
const vm = w.vm as unknown as {
|
|
dealsState: MockDeal[];
|
|
panelOpen: boolean;
|
|
selectedDeal: MockDeal | null;
|
|
openPanel: (d: MockDeal) => void;
|
|
};
|
|
vm.openPanel(vm.dealsState[0]);
|
|
expect(vm.panelOpen).toBe(true);
|
|
expect(vm.selectedDeal?.id).toBe(1);
|
|
vm.openPanel(vm.dealsState[0]);
|
|
expect(vm.panelOpen).toBe(false);
|
|
});
|
|
|
|
it('при selected=1 drawer авто-открывается, bulk-полоса скрыта (18.05.2026 ux)', async () => {
|
|
const w = await mountDeals();
|
|
const vm = w.vm as unknown as {
|
|
selected: number[];
|
|
panelOpen: boolean;
|
|
selectedDeal: MockDeal | null;
|
|
};
|
|
vm.selected = [1];
|
|
await flushPromises();
|
|
expect(vm.panelOpen).toBe(true);
|
|
expect(vm.selectedDeal?.id).toBe(1);
|
|
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('при selected≥2 drawer закрывается, bulk-полоса видна (18.05.2026 ux)', async () => {
|
|
const w = await mountDeals();
|
|
const vm = w.vm as unknown as {
|
|
selected: number[];
|
|
panelOpen: boolean;
|
|
dealsState: MockDeal[];
|
|
openPanel: (d: MockDeal) => void;
|
|
};
|
|
vm.openPanel(vm.dealsState[0]);
|
|
expect(vm.panelOpen).toBe(true);
|
|
vm.selected = [1, 2];
|
|
await flushPromises();
|
|
expect(vm.panelOpen).toBe(false);
|
|
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
|
|
const w = await mountDeals();
|
|
const vm = w.vm as unknown as {
|
|
selected: number[];
|
|
dealsState: MockDeal[];
|
|
applyBulkStatus: (s: string) => Promise<void>;
|
|
};
|
|
vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
|
|
vm.selected = [1, 2];
|
|
await flushPromises();
|
|
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
|
|
await vm.applyBulkStatus('viewed');
|
|
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed');
|
|
});
|
|
|
|
it('exportByRange xlsx вызывает exportDealsByRange', async () => {
|
|
const w = await mountDeals();
|
|
const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob());
|
|
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true });
|
|
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true });
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
|
const vm = w.vm as unknown as { exportByRange: (f: string) => Promise<void>; exportToastOpen: boolean };
|
|
await vm.exportByRange('xlsx');
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
expect(vm.exportToastOpen).toBe(true);
|
|
});
|
|
|
|
it('смена фильтра вызывает loadDeals ровно один раз (без двойного fetch)', async () => {
|
|
const w = await mountDeals();
|
|
const vm = w.vm as unknown as { filterStatus: string | null; page: number };
|
|
// Установим spy до смены page, чтобы перехватить все вызовы
|
|
const spy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals: [], total: 0, limit: 20, offset: 0 });
|
|
// Переходим на страницу 3 — это вызовет watch(page) → loadDeals один раз
|
|
vm.page = 3;
|
|
await flushPromises();
|
|
spy.mockClear(); // сбрасываем счётчик: интересует только смена фильтра
|
|
// Смена фильтра при page=3: A10 fix должен лишь сбросить page→1 (без прямого loadDeals),
|
|
// затем watch(page) делает ровно один fetch
|
|
vm.filterStatus = 'viewed';
|
|
await flushPromises();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('loadDeals reject → dealsState пустой + fetchError', async () => {
|
|
setActivePinia(createPinia());
|
|
const auth = useAuthStore();
|
|
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
|
|
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
|
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [{ path: '/deals', component: DealsView }],
|
|
});
|
|
await router.push('/deals');
|
|
await router.isReady();
|
|
const w = mount(DealsView, {
|
|
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
|
|
});
|
|
await flushPromises();
|
|
const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean };
|
|
expect(vm.dealsState.length).toBe(0);
|
|
expect(vm.fetchError).toBe(true);
|
|
});
|
|
});
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|