import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import { reactive, ref } from 'vue'; vi.mock('../../resources/js/api/autopodbor'); import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue'; import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; const vuetify = createVuetify(); function makeNav(competitorId: number | null = 3) { return { go: vi.fn(), ctx: reactive({ competitorId, editProjectId: null, selectedSourceIds: [] as number[] }), screen: ref('fieldcompetitor'), }; } function src(over: Partial = {}) { return { id: 10, competitor_id: 3, signal_type: 'site', identifier: 'okna.ru', phone_kind: null, phone_type: null, box: 'field', provenance_url: null, provenance_label: null, created_project_id: null, project: null, ...over, }; } function proj(over: Partial = {}) { return { id: 100, name: 'P', signal_identifier: 'okna.ru', is_active: true, paused_at: null, preflight_blocked_at: null, daily_limit_target: 5, delivered_in_month: 0, delivery_days_mask: 127, regions: [24], ...over, }; } function seed(store: any, sources: any[], comp: Partial = {}) { vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => { store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 90, ...comp } as any; store.sources = sources as any; }); } function mountFc(nav: any) { return mount(FieldCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); } describe('FieldCompetitorScreen', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); it('грузит конкурента и источники в работе', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10, identifier: 'okna.ru' })]); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); expect(store.loadCompetitor).toHaveBeenCalledWith(3); expect(w.text()).toContain('Окна Комфорт'); expect(w.text()).toContain('okna.ru'); }); it('источник без проекта показывает «Создать проект» и открывает окно создания', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10, project: null })]); const nav = makeNav(3); const w = mountFc(nav); await new Promise((r) => setTimeout(r, 0)); const btn = w.findAll('button').find((b) => b.text() === 'Создать проект'); expect(btn).toBeTruthy(); await btn!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('Создать проект из источника'); expect(w.text()).toContain('Дни недели приёма'); }); it('активный проект → «Приостановить» зовёт toggleProjectActive(false)', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]); const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue(); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const btn = w.findAll('button').find((b) => b.text() === 'Приостановить'); await btn!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(toggleSpy).toHaveBeenCalledWith(100, false); }); it('телефон показывает значок и тип номера', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 11, signal_type: 'call', identifier: '78432001122', phone_kind: 'substitute', phone_type: 'city' })]); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('🎭'); expect(w.text()).toContain('городской'); }); it('вкладка «Предложения» показывает источники-предложения и переносит «В работу»', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 12, box: 'proposal', identifier: 'prop.ru' })]); const moveSpy = vi.spyOn(store, 'moveSourceToBox').mockResolvedValue(); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const propTab = w.findAll('button').find((b) => b.text().includes('Предложения')); await propTab!.trigger('click'); expect(w.text()).toContain('prop.ru'); const btn = w.findAll('button').find((b) => b.text().includes('В источники')); await btn!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(moveSpy).toHaveBeenCalledWith(3, 12, 'field'); }); it('источник с проектом показывает живой источник проекта и меняет через change_source', async () => { const store = useAutopodborStore(); seed(store, [ src({ id: 10, signal_type: 'site', identifier: 'old.ru', project: proj({ id: 100, signal_identifier: 'live.ru', is_active: true }) }), ]); const changeSpy = vi.spyOn(store, 'changeProjectSource').mockResolvedValue({ source_change_message: 'Лиды дойдут.' }); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); // карточка показывает источник проекта, а не old.ru expect(w.text()).toContain('live.ru'); const editBtn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник')); await editBtn!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('Сменить источник?'); const input = w.find('.ld-modal input'); await input.setValue('new.ru'); // первый клик «Сохранить» — показывает подтверждение let save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сохранить'); await save!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('Подтвердите смену источника'); // второй клик «Сменить источник» — выполняет change_source save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сменить источник'); await save!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(changeSpy).toHaveBeenCalledWith(100, 'new.ru'); }); it('массовое «Приостановить выбранные» паузит проекты выбранных источников', async () => { const store = useAutopodborStore(); seed(store, [ src({ id: 10, project: proj({ id: 100, signal_identifier: 'a.ru', is_active: true }) }), src({ id: 11, identifier: 'b.ru', project: proj({ id: 101, signal_identifier: 'b.ru', is_active: true }) }), ]); const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue(); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const boxes = w.findAll('.ld-pick'); await boxes[0].trigger('change'); await boxes[1].trigger('change'); const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Приостановить')); await btn!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(toggleSpy).toHaveBeenCalledWith(100, false); expect(toggleSpy).toHaveBeenCalledWith(101, false); }); it('у изучённого конкурента нет кнопки «Собрать источники», показано «Источники собраны»', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10 })], { studied_at: '2026-06-30T00:00:00+00:00' }); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeFalsy(); expect(w.text()).toContain('Источники собраны'); }); it('неизучённый конкурент показывает кнопку «Собрать источники для меня»', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10 })], { studied_at: null }); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeTruthy(); }); it('сортирует источники: больше подтверждений — выше', async () => { const store = useAutopodborStore(); seed(store, [ src({ id: 10, signal_type: 'call', identifier: 'мало-номер', phone_kind: 'real', phone_type: 'city', confirmations: 1, where_found: [{ label: 'в коде сайта', url: null }] }), src({ id: 11, signal_type: 'call', identifier: 'много-номер', phone_kind: 'real', phone_type: 'city', confirmations: 3, where_found: [{ label: 'в коде сайта', url: 'https://k.ru' }, { label: '2ГИС', url: 'https://2gis.ru/x' }, { label: 'Контакты', url: null }] }), ]); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const cards = w.findAll('.ld-srccard'); expect(cards[0].text()).toContain('много-номер'); expect(cards[1].text()).toContain('мало-номер'); }); it('«где нашли» показывает кликабельные ссылки', async () => { const store = useAutopodborStore(); seed(store, [ src({ id: 10, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', phone_type: 'city', confirmations: 2, where_found: [{ label: 'в коде сайта', url: 'https://k.ru' }, { label: '2ГИС · ул. Весны 7а', url: 'https://2gis.ru/firm/1' }] }), ]); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const links = w.findAll('a.ld-fchip'); expect(links.length).toBe(2); expect(links[0].attributes('href')).toBe('https://k.ru'); expect(links[1].text()).toContain('Весны'); }); it('не делает кликабельной ссылку с javascript:-схемой (XSS-защита)', async () => { const store = useAutopodborStore(); seed(store, [ src({ id: 10, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', phone_type: 'city', where_found: [{ label: 'плохая', url: 'javascript:alert(1)' }, { label: '2ГИС', url: 'https://2gis.ru/x' }] }), ]); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const links = w.findAll('a.ld-fchip'); expect(links.length).toBe(1); // только https кликабельна expect(links[0].attributes('href')).toBe('https://2gis.ru/x'); expect(links.some((a) => (a.attributes('href') ?? '').toLowerCase().startsWith('javascript'))).toBe(false); expect(w.text()).toContain('плохая'); // не потеряли — показана простым текстом }); it('окно «Изменить источник» открывается с залоченным типом', async () => { const store = useAutopodborStore(); seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]); const w = mountFc(makeNav(3)); await new Promise((r) => setTimeout(r, 0)); const btn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник')); await btn!.trigger('click'); await new Promise((r) => setTimeout(r, 0)); expect(w.text()).toContain('тип не меняется'); }); });