Files
portal/app/tests/Frontend/SalesProspectDialog.spec.ts
T
Дмитрий 975075384a feat(воронка продаж): напоминание, куда отправляли тест и КП
Серая строка только для чтения в блоке результата — менеджеру перед звонком
не нужно лезть за этим в историю разговоров.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:54:29 +03:00

530 lines
24 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',
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();
vm.kpTarget = 'director@primer-firma.ru';
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 = '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 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('');
});
});