aed2f842ea
Фаза 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>
286 lines
12 KiB
TypeScript
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]);
|
|
});
|
|
});
|