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 { 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, notes: null, ...overrides, }; } interface DialogVm { action: string; reason: string; nextCallAt: string; email: 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; 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) => (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).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; 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).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).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).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('Взял в работу'); }); });