import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import SalesWarmingView from '../../resources/js/views/sales/SalesWarmingView.vue'; import * as api from '../../resources/js/api/sales'; import type { WarmingPlatformState, SalesAdAudienceFirmRow } from '../../resources/js/api/sales'; /** * Task «Три площадки прогрева» (2026-07-21, фронтенд): единый экран * SalesWarmingView.vue параметризован через prop platform (route * /sales/warming/:platform) — заменяет прежний единый SalesAdAudienceView.vue * с тремя платформенными особенностями: * - Яндекс: показывает номер списка (сегмент) из external_id; * - ВК: показывает человеческий статус доступа; * - Телеграм (МТС): ручной режим — кнопка скачивания файла вместо сегмента. * * Мокаем api/sales.ts, как и все существующие app/tests/Frontend/*.spec.ts — * экран ходит в сервер только через эту обёртку. */ vi.mock('../../resources/js/api/sales', async () => { const actual = await vi.importActual('../../resources/js/api/sales'); return { ...actual, getWarming: vi.fn(), updateWarming: vi.fn(), listWarmingFirms: vi.fn(), warmFirms: vi.fn(), unassignFirms: vi.fn(), assignWarmingFirm: vi.fn(), listSalesManagers: vi.fn(), downloadWarmingMtsFile: vi.fn(), }; }); const DURATIONS = { warmup_days: 3, new_days: 3, in_work_days: 14, negotiation_overdue_days: 3, negotiation_far_threshold_days: 14, negotiation_far_head_days: 3, negotiation_far_lead_days: 3, no_answer_days: 7, rejected_days: 3, registered_days: 30, testing_days: 30, }; const base: WarmingPlatformState = { platform: 'mts', enabled: false, min_phones: 5000, external_id: null, last_synced_at: null, last_error: null, status: 'ok', days: 30, durations: DURATIONS, in_ads: 0, expiring_week: 0, waiting_sync: 0, }; /** Task 7: строка фирмы теперь несёт нишу/дату запуска/менеджера — минимальный валидный мок. */ function firm(over: Partial): SalesAdAudienceFirmRow { return { id: 1, firm_name: 'Тестовая фирма', city: 'Москва', phones_count: 1, state: 'active', stop_reason: null, resume_at: null, days_in_ads: 1, prospect_id: null, stage: null, assigned: false, ch_yandex: false, ch_vk: false, ch_mts: false, rubric: null, warmup_started_at: null, manager_id: null, manager_name: null, warming_active: true, warming_channels: [], channel_status: 'warming', ...over, }; } const vuetify = createVuetify(); const factory = (platform: 'yandex' | 'vk' | 'mts') => mount(SalesWarmingView, { props: { platform }, global: { plugins: [vuetify] }, }); beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getWarming).mockResolvedValue({ ...base }); vi.mocked(api.listWarmingFirms).mockResolvedValue([]); }); describe('SalesWarmingView', () => { it('на Телеграме показывает ручной режим (кнопку файла), без сегмента', async () => { const w = factory('mts'); await flushPromises(); expect(w.find('[data-testid="mts-file"]').exists()).toBe(true); expect(w.find('[data-testid="segment-id"]').exists()).toBe(false); expect(w.find('[data-testid="vk-status"]').exists()).toBe(false); }); it('на Яндексе грузит состояние именно yandex и показывает сегмент', async () => { vi.mocked(api.getWarming).mockResolvedValue({ ...base, platform: 'yandex', min_phones: 100, external_id: 555, }); const w = factory('yandex'); await flushPromises(); expect(api.getWarming).toHaveBeenCalledWith('yandex'); expect(w.find('[data-testid="mts-file"]').exists()).toBe(false); expect(w.find('[data-testid="segment-id"]').text()).toBe('555'); }); it('на ВК показывает человеческий статус доступа', async () => { vi.mocked(api.getWarming).mockResolvedValue({ ...base, platform: 'vk', min_phones: 2000, status: 'waiting_volume', }); const w = factory('vk'); await flushPromises(); expect(api.getWarming).toHaveBeenCalledWith('vk'); expect(w.find('[data-testid="vk-status"]').text()).toContain('ждёт объёма — нужно от 2000 номеров'); expect(w.find('[data-testid="mts-file"]').exists()).toBe(false); }); it('«Мало номеров» использует настоящий порог площадки, а не число 100', async () => { vi.mocked(api.getWarming).mockResolvedValue({ ...base, platform: 'mts', min_phones: 5000, in_ads: 12, }); const w = factory('mts'); await flushPromises(); const alert = w.find('[data-testid="ads-too-few"]'); expect(alert.exists()).toBe(true); expect(alert.text()).toMatch(/5\s000/); expect(alert.text()).not.toContain('100 номеров'); }); it('рубильник зовёт updateWarming с текущей площадкой', async () => { vi.mocked(api.updateWarming).mockResolvedValue({ ...base, platform: 'vk', enabled: true }); const w = factory('vk'); await flushPromises(); type Exposed = { saveEnabled: (v: boolean | null) => Promise }; await (w.vm as unknown as Exposed).saveEnabled(true); await flushPromises(); expect(api.updateWarming).toHaveBeenCalledWith('vk', { enabled: true }); }); it('выбор + «Греть» по воронке зовёт warmFirms без days', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 1, ch_vk: true, phones_count: 2 }), firm({ id: 2, ch_vk: true, phones_count: 3 }), ]); vi.mocked(api.warmFirms).mockResolvedValue(undefined); const w = factory('vk'); await flushPromises(); expect(api.listWarmingFirms).toHaveBeenCalledWith('vk'); type Exposed = { selectedFirmIds: number[]; warmMode: 'funnel' | 'flat'; confirmWarm: () => Promise; }; const vm = w.vm as unknown as Exposed; vm.selectedFirmIds = [1, 2]; vm.warmMode = 'funnel'; await vm.confirmWarm(); await flushPromises(); expect(api.warmFirms).toHaveBeenCalledWith('vk', [1, 2], undefined); // Успех очищает выбор и перегружает список. expect(vm.selectedFirmIds).toEqual([]); }); it('«Греть» простым сроком шлёт days', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 7, ch_yandex: true })]); vi.mocked(api.warmFirms).mockResolvedValue(undefined); const w = factory('yandex'); await flushPromises(); type Exposed = { selectedFirmIds: number[]; warmMode: 'funnel' | 'flat'; warmDays: number; confirmWarm: () => Promise; }; const vm = w.vm as unknown as Exposed; vm.selectedFirmIds = [7]; vm.warmMode = 'flat'; vm.warmDays = 45; await vm.confirmWarm(); await flushPromises(); expect(api.warmFirms).toHaveBeenCalledWith('yandex', [7], 45); }); it('«Греть» простым сроком вне 1..365 не шлёт запрос', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 7, ch_yandex: true })]); vi.mocked(api.warmFirms).mockResolvedValue(undefined); const w = factory('yandex'); await flushPromises(); type Exposed = { selectedFirmIds: number[]; warmMode: 'funnel' | 'flat'; warmDays: number; warmConfirmDisabled: boolean; confirmWarm: () => Promise; }; const vm = w.vm as unknown as Exposed; vm.selectedFirmIds = [7]; vm.warmMode = 'flat'; vm.warmDays = 999; await flushPromises(); expect(vm.warmConfirmDisabled).toBe(true); await vm.confirmWarm(); await flushPromises(); expect(api.warmFirms).not.toHaveBeenCalled(); }); it('«Убрать» массово зовёт unassignFirms с выбранными id', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 1, ch_vk: true }), firm({ id: 2, ch_vk: true }), ]); vi.mocked(api.unassignFirms).mockResolvedValue(undefined); const w = factory('vk'); await flushPromises(); type Exposed = { selectedFirmIds: number[]; confirmRemove: () => Promise }; const vm = w.vm as unknown as Exposed; vm.selectedFirmIds = [1, 2]; await vm.confirmRemove(); await flushPromises(); expect(api.unassignFirms).toHaveBeenCalledWith('vk', [1, 2]); expect(vm.selectedFirmIds).toEqual([]); }); it('построчное «Убрать» зовёт unassignFirms на одну фирму', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 5, ch_yandex: true })]); vi.mocked(api.unassignFirms).mockResolvedValue(undefined); const w = factory('yandex'); await flushPromises(); type Exposed = { removeOne: (row: { id: number }) => Promise }; await (w.vm as unknown as Exposed).removeOne({ id: 5 }); await flushPromises(); expect(api.unassignFirms).toHaveBeenCalledWith('yandex', [5]); }); it('тоггла «Греть здесь» больше нет', async () => { const actual = await vi.importActual>('../../resources/js/api/sales'); expect(actual.toggleWarmingFirm).toBeUndefined(); expect(typeof actual.warmFirms).toBe('function'); expect(typeof actual.unassignFirms).toBe('function'); }); it('loaded-строка показывает «Ждёт запуска»', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 1, channel_status: 'loaded', state: 'stopped', warming_active: false }), ]); const w = factory('yandex'); await flushPromises(); expect(w.find('[data-testid="firms-table"]').text()).toContain('Ждёт запуска'); }); it('назначение менеджера зовёт assignWarmingFirm с текущей площадкой', async () => { vi.mocked(api.assignWarmingFirm).mockResolvedValue({ prospect_id: 42 }); vi.mocked(api.listSalesManagers).mockResolvedValue([ { id: 9, name: 'Иван Иванов', email: 'i.ivanov@liderra.ru', role: 'manager', is_active: true, clients_count: 3, paid_all_time_rub: 10000 }, ]); const w = factory('yandex'); await flushPromises(); type Exposed = { assignFirm: { id: number; firm_name: string } | null; assignManagerId: number | null; openAssign: (row: unknown) => void; doAssign: () => Promise; }; const vm = w.vm as unknown as Exposed; vm.openAssign({ id: 7, firm_name: 'Вторая клиника' }); await flushPromises(); vm.assignManagerId = 9; await vm.doAssign(); await flushPromises(); expect(api.assignWarmingFirm).toHaveBeenCalledWith('yandex', 7, 9); }); // ─── Task 7: пагинация, ниша/дата, «Отметить все», менеджер, фильтры ──── it('«Отметить все» выделяет все фирмы по фильтру без сетевых вызовов', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 1, ch_yandex: true, phones_count: 2 }), firm({ id: 2, ch_yandex: true, phones_count: 3 }), ]); const w = factory('yandex'); await flushPromises(); expect(w.find('[data-testid="warming-check-all"]').exists()).toBe(true); type Exposed = { toggleSelectAll: (v: boolean | null) => void; selectedFirmIds: number[] }; const vm = w.vm as unknown as Exposed; vm.toggleSelectAll(true); await flushPromises(); expect(vm.selectedFirmIds).toEqual([1, 2]); expect(w.find('[data-testid="warming-selected-count"]').text()).toContain('2'); // Галочка — это ВЫБОР, а не сеть: ни «Греть», ни «Убрать» не вызывались. expect(api.warmFirms).not.toHaveBeenCalled(); expect(api.unassignFirms).not.toHaveBeenCalled(); }); it('таблица показывает колонки «Ниша» и «Дата запуска»', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 1, rubric: 'Стоматологии' })]); const w = factory('yandex'); await flushPromises(); const tableText = w.find('[data-testid="firms-table"]').text(); expect(tableText).toContain('Ниша'); expect(tableText).toContain('Дата запуска'); }); it('назначенная фирма показывает имя менеджера и без кнопки назначения', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 5, assigned: true, manager_name: 'Таня' }), ]); const w = factory('yandex'); await flushPromises(); expect(w.find('[data-testid="warming-manager-name"]').text()).toBe('Таня'); expect(w.find('[data-testid="assign-btn-5"]').exists()).toBe(false); expect(w.find('[data-testid="warming-assign-btn"]').exists()).toBe(false); }); it('фирма без привязки, но с менеджером по ИНН — показывает менеджера и этап канбана, без кнопки', async () => { // assigned=false: строка прогрева не привязана к карточке, но клиента уже // ведут (карточка нашлась по ИНН). Показываем КТО и на каком этапе — не «Назначить». vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 8, assigned: false, manager_name: 'Пётр', stage: 'no_answer' }), ]); const w = factory('yandex'); await flushPromises(); expect(w.find('[data-testid="warming-manager-name"]').text()).toBe('Пётр'); expect(w.find('[data-testid="warming-manager-stage"]').text()).toContain('Не смогли дозвониться'); expect(w.find('[data-testid="warming-assign-btn"]').exists()).toBe(false); }); it('фильтр по нише сужает filtered до совпадающих фирм', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([ firm({ id: 1, rubric: 'Стоматологии' }), firm({ id: 2, rubric: 'Медицина' }), ]); const w = factory('yandex'); await flushPromises(); type Exposed = { filters: { niche: string | null }; filtered: Array<{ id: number }> }; const vm = w.vm as unknown as Exposed; vm.filters.niche = 'Медицина'; await flushPromises(); expect(vm.filtered.map((f) => f.id)).toEqual([2]); }); it('пагинация таблицы предлагает вариант 100 строк на странице', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 1 })]); const w = factory('yandex'); await flushPromises(); const table = w.findComponent({ name: 'VDataTable' }); expect(table.exists()).toBe(true); expect(table.props('itemsPerPageOptions')).toContain(100); }); // ─── Устойчивость: ошибка «Убрать» показывается снэкбаром, а не тишиной ──── it('«Убрать» при ошибке сервера показывает снэкбар и не роняет экран', async () => { vi.mocked(api.listWarmingFirms).mockResolvedValue([firm({ id: 1, ch_yandex: true })]); vi.mocked(api.unassignFirms).mockRejectedValue(new Error('сервер упал')); const w = factory('yandex'); await flushPromises(); type Exposed = { selectedFirmIds: number[]; confirmRemove: () => Promise; removing: boolean; snackbarText: string; }; const vm = w.vm as unknown as Exposed; vm.selectedFirmIds = [1]; await expect(vm.confirmRemove()).resolves.toBeUndefined(); await flushPromises(); expect(vm.snackbarText.length).toBeGreaterThan(0); expect(vm.removing).toBe(false); }); // ─── Фаза 1 (раздельные сроки): подсказка про сроки — про ЭТУ площадку ──── it('подсказка про сроки говорит про эту площадку, а не про общие для всех', async () => { const w = factory('yandex'); await flushPromises(); const text = w.text(); expect(text).not.toContain('общие сроки'); expect(text).not.toContain('одинаково на Яндекс'); expect(text).toContain('только для этой площадки'); }); });