Files
portal/app/tests/Frontend/SalesProspectDialog.spec.ts
T
Дмитрий 9ee4e9a79f feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны
- 12-я колонка «Корзина», результат «В корзину» просит только причину;
- в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного
  тестирования»; при нуле дробь не рисуется — «69/0» это шум;
- у «Выслано КП» поле даты подписано «если договорились» и необязательно;
- орган фильтра по датам на ОБОИХ экранах, два режима + период.

Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела»
(снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/).
Браузер поймал то, чего не видели тесты: при смене режима оставался прежний
период, и «что менялось за завтра» давало пустую доску — теперь период
возвращается к «Сегодня», на это заведён отдельный тест.

Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
2026-08-01 13:29:10 +03:00

591 lines
27 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 { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('../../resources/js/api/sales', () => ({
updateProspectContacts: vi.fn().mockResolvedValue({}),
listProspectNotes: vi.fn().mockResolvedValue([]),
addProspectNote: vi.fn(),
}));
import SalesProspectDialog from '../../resources/js/components/sales/SalesProspectDialog.vue';
import { updateProspectContacts, listProspectNotes, addProspectNote } from '../../resources/js/api/sales';
import type { SalesProspect } from '../../resources/js/api/sales';
const vuetify = createVuetify();
function prospect(overrides: Partial<SalesProspect> = {}): SalesProspect {
return {
id: 1,
sales_user_id: 1,
stage: 'new',
prev_stage: null,
firm_name: 'ООО Тест',
city: 'Ростов',
phone: '+7800',
site: 'test.ru',
inn: '6161',
rating_label: 'горячая',
source: 'search',
legal_name: null,
contacts: [],
payload: {},
next_call_at: null,
reason: null,
registered_email: null,
test_channel: null,
test_target: null,
kp_channel: null,
kp_target: null,
notes: null,
...overrides,
};
}
interface DialogVm {
action: string;
reason: string;
nextCallAt: string;
email: string;
testChannel: string;
testTarget: string;
kpChannel: string;
kpTarget: string;
resultReminder: string;
siteHref: string | null;
availableActions: { value: string; title: string }[];
infoRows: { label: string; value: string; href?: string }[];
contactRows: { name: string; position: string; phones: string[] }[];
notes: { id: number; kind: string; title: string | null; body: string; author: string | null; created_at: string }[];
summary: string;
submit: () => Promise<void>;
error: string;
}
function factory(p: SalesProspect) {
return mount(SalesProspectDialog, {
props: { modelValue: true, prospect: p },
global: { plugins: [vuetify] },
});
}
describe('SalesProspectDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});
it('siteHref добавляет https к домену', () => {
const w = factory(prospect());
expect((w.vm as unknown as DialogVm).siteHref).toBe('https://test.ru');
});
it('для stage=user действие «Отказ» недоступно', () => {
const w = factory(prospect({ stage: 'user' }));
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
expect(values).not.toContain('rejected');
});
it('для обычной стадии «Отказ» доступен', () => {
const w = factory(prospect({ stage: 'negotiation' }));
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
expect(values).toContain('rejected');
});
it('«Переговоры» без даты — submit не эмитит save', () => {
const w = factory(prospect());
(w.vm as unknown as DialogVm).action = 'negotiation';
(w.vm as unknown as DialogVm).submit();
expect(w.emitted('save')).toBeFalsy();
});
it('«Отказ» с причиной — эмитит save с action=rejected', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
(w.vm as unknown as DialogVm).action = 'rejected';
(w.vm as unknown as DialogVm).reason = 'дорого';
await (w.vm as unknown as DialogVm).submit();
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'rejected', reason: 'дорого' });
});
it('infoRows показывает ВСЕ данные поиска из payload', () => {
const w = factory(
prospect({
inn: '6161000000',
payload: {
legal_name: 'ООО «Тест»',
ogrn: '1234567890123',
address: 'г. Ростов, ул. Ленина 1',
legal_status: 'действующая',
director: 'Иванов Иван Иванович',
director_post: 'директор',
director_is_ip: false,
director_inn: '616100000000',
contact_phone: '+79001234567',
contact_email: 'ivan@test.ru',
direct_budget_min: 80000,
direct_budget: 250000,
direct_keys: 1200,
direct_ads: 30,
channels: ['Яндекс.Директ', 'коллтрекинг'],
},
}),
);
const rows = (w.vm as unknown as DialogVm).infoRows;
const text = rows.map((r) => `${r.label}: ${r.value}`).join(' | ');
// Показываем — из девятки владельца (§18)
expect(text).toContain('Иванов Иван Иванович'); // директор
expect(text).toContain('директор'); // должность
expect(text).toContain('+79001234567'); // тел. директора (предполагаемый)
expect(text).toContain('коллтрекинг'); // каналы
expect(text).toContain('6161000000'); // ИНН фирмы
expect(text).toContain('г. Ростов, ул. Ленина 1'); // адрес
expect(text).toMatch(/80 000.*250 000/); // вилка бюджета
// НЕ показываем — вне девятки
expect(text).not.toContain('616100000000'); // личный ИНН руководителя (ПДн)
expect(text).not.toContain('ivan@test.ru'); // почта директора
expect(text).not.toContain('1234567890123'); // ОГРН
expect(text).not.toContain('1 200'); // запросы в Директе
});
it('«Зарегистрировался» доступен на обычной стадии, скрыт на user', () => {
const a = (w: ReturnType<typeof factory>) => (w.vm as unknown as DialogVm).availableActions.map((x) => x.value);
expect(a(factory(prospect({ stage: 'negotiation' })))).toContain('registered');
expect(a(factory(prospect({ stage: 'user' })))).not.toContain('registered');
expect(a(factory(prospect({ stage: 'testing' })))).not.toContain('registered');
});
it('«Зарегистрировался» без e-mail — save не эмитится; с e-mail — эмитит {action:registered,email}', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
(w.vm as unknown as DialogVm).action = 'registered';
await (w.vm as unknown as DialogVm).submit();
expect(w.emitted('save')).toBeFalsy();
(w.vm as unknown as DialogVm).email = 'client@example.com';
await (w.vm as unknown as DialogVm).submit();
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'registered', email: 'client@example.com' });
});
it('«Вернуть в Новые» есть только на стадии «Взят в работу» и эмитит back_to_new', async () => {
const inWork = factory(prospect({ stage: 'in_work' }));
const vm = inWork.vm as unknown as DialogVm & { canReturnToNew: boolean; returnToNew: () => void };
expect(vm.canReturnToNew).toBe(true);
vm.returnToNew();
expect(inWork.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'back_to_new' });
const other = factory(prospect({ stage: 'negotiation' }));
expect((other.vm as unknown as { canReturnToNew: boolean }).canReturnToNew).toBe(false);
});
it('infoRows пропускает пустые поля payload', () => {
const w = factory(prospect({ payload: {}, inn: null }));
const labels = (w.vm as unknown as DialogVm).infoRows.map((r) => r.label);
expect(labels).not.toContain('ОГРН');
expect(labels).not.toContain('Директор');
});
it('юрлицо из карточки показывается в списке данных', () => {
const w = factory(prospect({ legal_name: 'ООО «Улыбка Плюс»' }));
const rows = (w.vm as unknown as DialogVm).infoRows;
expect(rows.find((r) => r.label === 'Юрлицо')?.value).toBe('ООО «Улыбка Плюс»');
});
it('юрлицо из карточки важнее старого значения из поиска', () => {
const w = factory(
prospect({ legal_name: 'ООО «Из карточки»', payload: { legal_name: 'ООО «Из поиска»' } }),
);
const rows = (w.vm as unknown as DialogVm).infoRows;
expect(rows.find((r) => r.label === 'Юрлицо')?.value).toBe('ООО «Из карточки»');
});
it('показывает ТОЛЬКО девять полей владельца, в его порядке', () => {
const w = factory(
prospect({
legal_name: 'ООО "ОМДЕНТ"',
site: 'omdent.ru',
inn: '5501261235',
phone: '+73812244222',
payload: {
address: 'г Омск, ул Химиков, д 34',
channels: ['Яндекс.Директ', 'VK-myTarget (пиксель)'],
direct_budget_min: 10415,
direct_budget: 23656,
director: 'Захаров Александр Владимирович',
director_post: 'Генеральный директор',
contact_phone: '9136518088, 9133343217',
// лишнее из поиска — показывать НЕ должны
ogrn: '1145543043095',
legal_status: 'действующая',
director_inn: '550405815600',
contact_email: 'zav5700@mail.ru',
confidence: 'высокая',
advertises: 'да',
direct_keys: 8,
},
}),
);
const labels = (w.vm as unknown as DialogVm).infoRows.map((r) => r.label);
expect(labels).toEqual([
'Юрлицо',
'Сайт',
'ИНН фирмы',
'Адрес',
'Телефон общий',
'Каналы',
'Бюджет',
'Директор',
'Тел. директора (предполагаемый)',
]);
});
it('оценка, ОГРН, почта директора и прочее больше не показываются', () => {
const w = factory(
prospect({ rating_label: 'горячая', payload: { ogrn: '114', contact_email: 'z@m.ru' } }),
);
const labels = (w.vm as unknown as DialogVm).infoRows.map((r) => r.label);
expect(labels).not.toContain('Оценка');
expect(labels).not.toContain('ОГРН');
expect(labels).not.toContain('Почта директора');
expect(labels).not.toContain('Достоверность');
});
it('пустые поля не показываются', () => {
const w = factory(prospect({ legal_name: null, site: null, inn: null, phone: null, payload: {} }));
expect((w.vm as unknown as DialogVm).infoRows).toEqual([]);
});
it('директор с должностью склеивается в одну строку', () => {
const w = factory(
prospect({ payload: { director: 'Захаров А. В.', director_post: 'Генеральный директор' } }),
);
const rows = (w.vm as unknown as DialogVm).infoRows;
expect(rows.find((r) => r.label === 'Директор')?.value).toBe('Захаров А. В. — Генеральный директор');
});
it('бюджет показывается вилкой', () => {
const w = factory(prospect({ payload: { direct_budget_min: 10415, direct_budget: 23656 } }));
const rows = (w.vm as unknown as DialogVm).infoRows;
expect(rows.find((r) => r.label === 'Бюджет')?.value).toBe('10 415 23 656 ₽/мес');
});
it('контакты карточки разворачиваются в форму для правки', () => {
const w = factory(
prospect({ contacts: [{ name: 'Захаров', position: 'директор', phones: ['+79136518088'] }] }),
);
const rows = (w.vm as unknown as DialogVm).contactRows;
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({ name: 'Захаров', position: 'директор', phones: ['+79136518088'] });
});
it('без контактов даёт одну пустую строку — есть куда записать после обзвона', () => {
const w = factory(prospect());
const rows = (w.vm as unknown as DialogVm).contactRows;
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('');
});
it('журнал подгружается при открытии карточки', async () => {
(listProspectNotes as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 2, kind: 'note', title: null, body: 'СВЕЖАЯ', author: 'Таня', created_at: '2026-07-18T10:00:00+03:00' },
{ id: 1, kind: 'stage', title: null, body: 'Взял в работу', author: 'Таня', created_at: '2026-07-17T10:00:00+03:00' },
]);
const w = factory(prospect({ id: 7 }));
await flushPromises();
expect(listProspectNotes).toHaveBeenCalledWith(7);
expect((w.vm as unknown as DialogVm).notes).toHaveLength(2);
expect((w.vm as unknown as DialogVm).notes[0].body).toBe('СВЕЖАЯ');
});
it('краткое содержание уходит вместе с результатом разговора', async () => {
const w = factory(prospect({ stage: 'in_work' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'negotiation';
vm.nextCallAt = '2026-07-20T10:30';
vm.summary = 'Директор просил перезвонить с ценами';
await vm.submit();
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({
action: 'negotiation',
next_call_at: '2026-07-20T10:30',
summary: 'Директор просил перезвонить с ценами',
});
});
it('недозвон отдаёт причину и дату следующего перезвона', async () => {
const w = factory(prospect({ stage: 'in_work' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'no_answer';
vm.reason = 'не берёт трубку';
vm.nextCallAt = '2026-07-21T09:00';
await vm.submit();
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({
action: 'no_answer',
reason: 'не берёт трубку',
next_call_at: '2026-07-21T09:00',
});
});
it('недозвон без даты перезвона тоже сохраняется', async () => {
const w = factory(prospect({ stage: 'in_work' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'no_answer';
vm.reason = 'занято';
await vm.submit();
const payload = w.emitted('save')?.[0]?.[0] as Record<string, unknown>;
expect(payload.action).toBe('no_answer');
expect(payload.next_call_at ?? null).toBeNull();
});
it('одна кнопка «Сохранить»: контакты уходят вместе с результатом', async () => {
const w = factory(prospect({ id: 42, stage: 'in_work' }));
const vm = w.vm as unknown as DialogVm;
vm.contactRows = [
{ name: 'Денчик', position: 'Дир', phones: ['9990000009', ''] },
{ name: '', position: '', phones: [''] },
];
vm.action = 'negotiation';
vm.nextCallAt = '2026-07-20T17:35';
vm.summary = 'Сказал наберёт меня маркетолог';
await vm.submit();
await flushPromises();
expect(updateProspectContacts).toHaveBeenCalledWith(42, [
{ name: 'Денчик', position: 'Дир', phones: ['9990000009'] },
]);
expect(w.emitted('save')?.[0]?.[0]).toMatchObject({ action: 'negotiation' });
});
it('«Сохранить» без результата сохраняет контакты и закрывает окно', async () => {
const w = factory(prospect({ id: 42 }));
const vm = w.vm as unknown as DialogVm;
vm.contactRows = [{ name: 'Денчик', position: '', phones: ['9990000009'] }];
await vm.submit();
await flushPromises();
expect(updateProspectContacts).toHaveBeenCalled();
expect(w.emitted('save')).toBeFalsy();
expect(w.emitted('update:modelValue')?.[0]?.[0]).toBe(false);
});
it('контакты не сохранились → результат не отправляем, показываем ошибку', async () => {
(updateProspectContacts as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('нет сети'));
const w = factory(prospect({ id: 42, stage: 'in_work' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'no_answer';
vm.reason = 'занято';
await vm.submit();
await flushPromises();
expect(w.emitted('save')).toBeFalsy();
expect(vm.error).not.toBe('');
});
it('отдельной кнопки «Сохранить контакты» больше нет', () => {
const w = factory(prospect());
expect(w.text()).not.toContain('Сохранить контакты');
});
it('запись с заголовком: сверху что решили, снизу текст разговора', async () => {
(listProspectNotes as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 3,
kind: 'note',
title: 'Договорились на созвон 20.07.2026 17:35',
body: 'Сказал наберёт меня маркетолог',
author: 'Таня Михалева',
created_at: '2026-07-18T17:39:00+03:00',
},
]);
const w = factory(prospect({ id: 7 }));
await flushPromises();
const item = document.body.querySelector('.prospect-log-item');
expect(item?.querySelector('.prospect-log-title')?.textContent?.trim())
.toBe('Договорились на созвон 20.07.2026 17:35');
expect(item?.querySelector('.prospect-log-body')?.textContent?.trim())
.toBe('Сказал наберёт меня маркетолог');
});
it('запись без заголовка рисуется без пустой строки сверху', async () => {
(listProspectNotes as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, kind: 'stage', title: null, body: 'Взял в работу', author: 'Таня', created_at: '2026-07-18T10:00:00+03:00' },
]);
const w = factory(prospect({ id: 7 }));
await flushPromises();
expect(document.body.querySelector('.prospect-log-title')).toBeNull();
expect(document.body.querySelector('.prospect-log-body')?.textContent?.trim()).toBe('Взял в работу');
});
it('оба новых результата есть в списке на обычной стадии', () => {
const w = factory(prospect({ stage: 'negotiation' }));
const values = (w.vm as unknown as DialogVm).availableActions.map((a) => a.value);
expect(values).toContain('manual_testing');
expect(values).toContain('kp_sent');
});
it('платящему клиенту оба новых результата недоступны', () => {
for (const stage of ['topped_up', 'user']) {
const values = (factory(prospect({ stage })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).not.toContain('manual_testing');
expect(values).not.toContain('kp_sent');
}
});
it('после ручного тестирования «Зарегистрировался» остаётся доступен', () => {
const values = (factory(prospect({ stage: 'manual_testing' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).toContain('registered');
});
it('ручное тестирование: без канала, без значения и без даты — save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'manual_testing';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
vm.testChannel = 'phone';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
vm.testTarget = '79990001122';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('ручное тестирование: с каналом, значением и датой эмитит save с нужными полями', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'manual_testing';
vm.testChannel = 'site';
vm.testTarget = ' primer-firma.ru ';
vm.nextCallAt = '2026-08-05T12:00';
await vm.submit();
await flushPromises();
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'manual_testing',
test_channel: 'site',
test_target: 'primer-firma.ru',
next_call_at: '2026-08-05T12:00',
summary: '',
});
});
it('выслано КП: без канала и без адреса — save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'kp_sent';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
vm.kpChannel = 'email';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('выслано КП: без даты созвона всё равно сохраняется — дата необязательна', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'kp_sent';
vm.kpChannel = 'email';
vm.kpTarget = 'director@primer-firma.ru';
await vm.submit();
await flushPromises();
// Поля next_call_at в письме на сервер нет вовсе — не пустая строка.
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'kp_sent',
kp_channel: 'email',
kp_target: 'director@primer-firma.ru',
summary: '',
});
});
it('выслано КП: с каналом, адресом и датой эмитит save с нужными полями', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'kp_sent';
vm.kpChannel = 'whatsapp';
vm.kpTarget = '79990001122';
vm.nextCallAt = '2026-08-05T12:00';
await vm.submit();
await flushPromises();
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'kp_sent',
kp_channel: 'whatsapp',
kp_target: '79990001122',
next_call_at: '2026-08-05T12:00',
summary: '',
});
});
it('корзина: результат есть в списке на обычной стадии, платящему — нет', () => {
const onWork = (factory(prospect({ stage: 'negotiation' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(onWork).toContain('trash');
for (const stage of ['topped_up', 'user']) {
const values = (factory(prospect({ stage })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).not.toContain('trash');
}
});
it('корзина: без причины save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'trash';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('корзина: с причиной эмитит save без даты созвона', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'trash';
vm.reason = 'не наш профиль';
// Дату могли набрать до переключения результата — в корзину она уйти не должна.
vm.nextCallAt = '2026-08-05T12:00';
await vm.submit();
await flushPromises();
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'trash',
reason: 'не наш профиль',
summary: '',
});
});
it('корзина: из корзины можно вернуться — «Зарегистрировался» остаётся доступен', () => {
const values = (factory(prospect({ stage: 'trash' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).toContain('registered');
});
it('на карточке в ручном тестировании показывает, куда отправляли тест', () => {
const w = factory(prospect({ stage: 'manual_testing', test_channel: 'phone', test_target: '79990001122' }));
expect((w.vm as unknown as DialogVm).resultReminder).toBe('Тест отправлен: телефон 79990001122');
});
it('на карточке с высланным КП показывает, куда его отправляли', () => {
const w = factory(prospect({ stage: 'kp_sent', kp_channel: 'whatsapp', kp_target: '79990001122' }));
expect((w.vm as unknown as DialogVm).resultReminder).toBe('КП отправлено: ватсап 79990001122');
});
it('на обычной стадии напоминания нет', () => {
const w = factory(prospect({ stage: 'negotiation' }));
expect((w.vm as unknown as DialogVm).resultReminder).toBe('');
});
});