Files
portal/app/tests/Frontend/SalesSmsView.spec.ts
T
Дмитрий aed2f842ea feat(прогрев ф2 этап D): СМС-экран — свой список каналом + колонка «Отправлено N»
Фаза 2 Этап D, Task D2 (последняя задача фазы). SalesSmsView берёт свой список
через listSmsFirms (GET /ad-audience/sms-firms — фирмы со строкой firm_channels
sms), а не общий /ad-audience/firms. Добавлена колонка «Отправлено» = sms_sent_count
(сколько боевых СМС уже слали фирме; 0 → «—»), чтобы не долбить человека повторно.
Логика рассылки/выбора получателей/тарификация — без изменений. Vitest 13 (экран)
+ 1483 фронт, build ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:39:44 +03:00

286 lines
12 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SalesSmsView from '../../resources/js/views/sales/SalesSmsView.vue';
import * as api from '../../resources/js/api/sales';
import type { SalesAdAudienceFirmRow } from '../../resources/js/api/sales';
const view = readFileSync(
resolve(__dirname, '../../resources/js/views/sales/SalesSmsView.vue'),
'utf-8',
);
describe('Экран «Прогрев СМС»', () => {
it('показывает цену до отправки', () => {
expect(view).toContain('estimated_cost_kopecks');
});
it('помечает режим песочницы явно — «отправлено» нельзя спутать с настоящей отправкой', () => {
expect(view).toMatch(/песочниц/i);
});
it('блокирует кнопку без согласованного имени отправителя', () => {
expect(view).toMatch(/:disabled/);
expect(view).toMatch(/имя отправителя/i);
});
it('показывает счётчик сегментов', () => {
expect(view).toContain('segments');
});
it('показывает причины пропуска номеров', () => {
expect(view).toContain('skipped');
});
// Task 10.5: у начальника 99 фирм и 177 номеров — руками их не переписывают.
it('получателей отмечают галочками, а не вводят руками', () => {
expect(view).toContain('v-checkbox');
expect(view).toContain('fetchSmsRecipients');
});
it('ходит в сервер через общую прослойку, а не напрямую', () => {
expect(view).not.toContain('axios.post');
expect(view).not.toContain('axios.get');
});
});
// ─── Task 8: пагинация, Ниша/Дата запуска, назначение менеджера, фильтры ──────
//
// Мокаем api/sales.ts, как и SalesWarmingView.spec.ts — экран ходит в сервер
// только через эту обёртку.
vi.mock('../../resources/js/api/sales', async () => {
const actual = await vi.importActual<typeof import('../../resources/js/api/sales')>('../../resources/js/api/sales');
return {
...actual,
fetchSmsCampaigns: vi.fn(),
fetchSmsCampaign: vi.fn(),
fetchSmsRecipients: vi.fn(),
previewSms: vi.fn(),
createSmsCampaign: vi.fn(),
listSmsFirms: vi.fn(),
assignAdAudienceFirm: vi.fn(),
listSalesManagers: vi.fn(),
};
});
/** Task 8: строка фирмы несёт нишу/дату запуска/менеджера — минимальный валидный мок. */
function firm(over: Partial<SalesAdAudienceFirmRow>): 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: [],
...over,
};
}
const vuetify = createVuetify();
// VDialog в JSDOM не рендерит в teleport-цели; стаб делает <slot/> доступным
// внутри корня для wrapper.find() (тот же приём, что в NewProjectDialog.spec.ts).
const factory = () =>
mount(SalesSmsView, {
global: {
plugins: [vuetify],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.fetchSmsCampaigns).mockResolvedValue({ campaigns: [], sandbox: false, senders: [] });
vi.mocked(api.listSmsFirms).mockResolvedValue([]);
vi.mocked(api.fetchSmsRecipients).mockResolvedValue([]);
});
describe('SalesSmsView — пагинация, ниша/дата, менеджер, фильтры (Task 8)', () => {
it('таблица получателей показывает колонки «Ниша» и «Дата запуска»', async () => {
vi.mocked(api.listSmsFirms).mockResolvedValue([firm({ id: 1, rubric: 'Стоматологии' })]);
const w = factory();
await flushPromises();
const tableText = w.find('[data-testid="sms-firms-table"]').text();
expect(tableText).toContain('Ниша');
expect(tableText).toContain('Дата запуска');
});
// Task D2: список СМС-фирм идёт из своего источника (только firm_channels
// channel='sms') и показывает, сколько боевых СМС уже слали — чтобы не
// долбить человека повторно.
it('таблица получателей показывает колонку «Отправлено» со значением sms_sent_count', async () => {
vi.mocked(api.listSmsFirms).mockResolvedValue([
firm({ id: 1, firm_name: 'Стоматология Улыбка', sms_sent_count: 3 }),
]);
const w = factory();
await flushPromises();
expect(api.listSmsFirms).toHaveBeenCalled();
const tableText = w.find('[data-testid="sms-firms-table"]').text();
expect(tableText).toContain('Отправлено');
expect(tableText).toContain('3');
});
it('пагинация таблицы предлагает вариант 100 строк на странице', async () => {
vi.mocked(api.listSmsFirms).mockResolvedValue([firm({ id: 1 })]);
const w = factory();
await flushPromises();
const table = w.findComponent({ name: 'VDataTable' });
expect(table.exists()).toBe(true);
expect(table.props('itemsPerPageOptions')).toContain(100);
});
it('«Назначить менеджера» открывает диалог, подтверждение зовёт assignAdAudienceFirm', async () => {
vi.mocked(api.listSmsFirms).mockResolvedValue([
firm({ id: 3, firm_name: 'Клиника Феникс', assigned: false }),
]);
vi.mocked(api.listSalesManagers).mockResolvedValue([
{
id: 9,
name: 'Иван Иванов',
email: 'i.ivanov@liderra.ru',
role: 'manager',
is_active: true,
clients_count: 1,
paid_all_time_rub: 0,
},
]);
vi.mocked(api.assignAdAudienceFirm).mockResolvedValue({ prospect_id: 77 });
const w = factory();
await flushPromises();
const btn = w.find('[data-testid="warming-assign-btn"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await flushPromises();
expect(w.find('[data-testid="sms-assign-dialog"]').exists()).toBe(true);
type Exposed = { assignManagerId: number | null; doAssign: () => Promise<void> };
const vm = w.vm as unknown as Exposed;
vm.assignManagerId = 9;
await vm.doAssign();
await flushPromises();
expect(api.assignAdAudienceFirm).toHaveBeenCalledWith(3, 9);
});
it('фильтр по нише сужает filtered до совпадающих фирм', async () => {
vi.mocked(api.listSmsFirms).mockResolvedValue([
firm({ id: 1, rubric: 'Стоматологии' }),
firm({ id: 2, rubric: 'Медицина' }),
]);
const w = factory();
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('фильтр ниши прячет отмеченную фирму — её номера уходят из получателей, галочка сохраняется', async () => {
vi.mocked(api.listSmsFirms).mockResolvedValue([
firm({ id: 1, firm_name: 'Стоматология Улыбка', rubric: 'Стоматологии', phones_count: 2 }),
firm({ id: 2, firm_name: 'Клиника Медицина+', rubric: 'Медицина', phones_count: 3 }),
]);
vi.mocked(api.fetchSmsRecipients).mockImplementation(async (ids: number[]) => {
const map: Record<number, api.SalesSmsRecipientRow[]> = {
1: [
{
phone: '79990000001',
firm_name: 'Стоматология Улыбка',
city: 'Москва',
operator: null,
phone_type: null,
},
],
2: [
{
phone: '79990000002',
firm_name: 'Клиника Медицина+',
city: 'Москва',
operator: null,
phone_type: null,
},
],
};
return ids.flatMap((id) => map[id] ?? []);
});
const w = factory();
await flushPromises();
type Exposed = {
selectedFirmIds: number[];
effectiveSelectedIds: number[];
filters: { niche: string | null };
recipients: Array<{ phone: string }>;
};
const vm = w.vm as unknown as Exposed;
// Отмечаем обе фирмы галочками.
vm.selectedFirmIds = [1, 2];
await flushPromises();
expect(vm.effectiveSelectedIds.sort()).toEqual([1, 2]);
expect(api.fetchSmsRecipients).toHaveBeenLastCalledWith([1, 2]);
expect(vm.recipients.map((r) => r.phone).sort()).toEqual(['79990000001', '79990000002']);
// Фильтр ниши прячет фирму id=1 (Стоматологии).
vm.filters.niche = 'Медицина';
await flushPromises();
expect(vm.effectiveSelectedIds).toEqual([2]);
// Галочка НЕ снята — только скрытые из выдачи id не считаются получателями.
expect(vm.selectedFirmIds.sort()).toEqual([1, 2]);
expect(api.fetchSmsRecipients).toHaveBeenLastCalledWith([2]);
expect(vm.recipients.map((r) => r.phone)).toEqual(['79990000002']);
const counterText = w.find('[data-testid="sms-recipients-count"]').text();
expect(counterText).toContain('1');
expect(counterText).not.toMatch(/Отмечено фирм:\s*2/);
// Сняли фильтр — выбор восстанавливается целиком.
vm.filters.niche = null;
await flushPromises();
expect(vm.effectiveSelectedIds.sort()).toEqual([1, 2]);
});
});