Files
portal/app/tests/Frontend/NewProjectDialog.spec.ts
T
Дмитрий 9ff7bc8fa0 feat(биллинг-фронт): баланс на запуске — сообщения в рублях, метка «не запущен», группы
Фронт для правила «баланс блокирует запуск, а не создание»:
- ProjectLimitOverloadDialog переформулирован в «Проект не запущен — пополните
  ~X ₽ или уменьшите объём» (сумма в рублях topup_rub).
- NewProjectDialog: создание всегда успешно; при launch.deferred показывает
  сообщение (не блок). Update-лимита 409 → тот же диалог.
- Возобновление (toggle-active) ловит 409 balance_insufficient (стор не
  переключает is_active), показывает диалог; постоянная метка «Не запущен —
  не хватает баланса» на карточке/в панели по preflight_blocked_at/balance_blocked.
- BulkActionsBar: сводка «Запущено N, отложено M — не хватает баланса» на resume.
- Автоподбор: CreateScreen показывает сводку запуска (рубли); FieldCompetitor
  массовые pause/resume через общий POST /api/projects/bulk (наследует BULK_MAX,
  слепок-защиту, балансовый гейт).
- ProjectResource отдаёт preflight_blocked_at.

Новые/обновлённые фронт-тесты зелёные. Пред-существующий красный baseline
фронт-набора (ErrorView/Settings/Legal/… + ProjectsView v-show) не трогался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:04:16 +03:00

256 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import axios from 'axios';
vi.mock('axios');
vi.mock('../../resources/js/api/client', () => ({
apiClient: {
post: vi.fn().mockResolvedValue({ data: {} }),
patch: vi.fn().mockResolvedValue({ data: {} }),
},
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
}));
// Косяк 04 / вариант C: диалог при открытии тянет реквизиты. По умолчанию —
// light-complete, чтобы существующие тесты сразу попадали на шаг проекта.
vi.mock('../../resources/js/api/requisites', () => ({
getRequisites: vi.fn().mockResolvedValue({
subject_type: 'individual',
contact_name: 'Иван',
contact_phone: '+79161234567',
inn: null,
}),
updateRequisites: vi.fn().mockResolvedValue({}),
}));
import { apiClient } from '../../resources/js/api/client';
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
import type { Project } from '../../resources/js/stores/projectsStore';
// VDialog в JSDOM не рендерит в teleport-цели; стаб делает <slot/> доступным
// внутри корня для wrapper.text() / find().
const factory = (
props: { modelValue: boolean; mode?: 'create' | 'edit'; project?: Project | null } = {
modelValue: true,
mode: 'create',
},
) =>
mount(NewProjectDialog, {
props,
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe('NewProjectDialog', () => {
it('renders 3 tabs: Сайт / Звонок / СМС', async () => {
const wrapper = factory();
await flushPromises();
const text = wrapper.text();
expect(text).toContain('Сайт');
expect(text).toContain('Звонок');
expect(text).toContain('СМС');
});
it('shows hint «Как увеличить количество сделок» with explanatory tooltip', async () => {
const wrapper = factory();
await flushPromises();
expect(wrapper.text()).toContain('Как увеличить количество сделок');
const tips = wrapper.findAllComponents({ name: 'VTooltip' });
const boost = tips.find((t) =>
String(t.props('text')).includes('распределяется на нескольких поставщиков'),
);
expect(boost).toBeTruthy();
expect(String(boost?.props('text'))).toContain('увеличьте лимит');
});
it('switching to SMS tab shows sms_senders field', async () => {
const wrapper = factory();
await flushPromises();
const tabs = wrapper.findComponent({ name: 'VTabs' });
if (tabs.exists()) {
tabs.vm.$emit('update:modelValue', 'sms');
}
await flushPromises();
expect(wrapper.text()).toMatch(/Отправители|sms_senders/i);
});
it('validation: empty site domain does not POST (button stays available, axios.post not called by default)', async () => {
const wrapper = factory();
await flushPromises();
const btn = wrapper.find('[data-testid="submit-btn"]');
expect(btn.exists()).toBe(true);
// Не нажимаем — проверяем, что данные формы по умолчанию пустые и POST ещё не вызван.
expect((axios.post as ReturnType<typeof vi.fn>).mock?.calls?.length ?? 0).toBe(0);
});
it('требует название: пустое имя блокирует POST и показывает ошибку', async () => {
const wrapper = factory();
await flushPromises();
const vm = wrapper.vm as unknown as {
confirmVsyaRf: () => void;
submit: () => Promise<void>;
form: { name: string; signal_identifier: string };
};
vm.confirmVsyaRf(); // проходим гейт обязательного региона
vm.form.signal_identifier = 'okna-konkurent.ru'; // источник заполнен
vm.form.name = ''; // имя пустое
await vm.submit();
await flushPromises();
expect(apiClient.post).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Введите название проекта');
});
it('требует источник: пустой домен сайта блокирует POST и показывает ошибку', async () => {
const wrapper = factory();
await flushPromises();
const vm = wrapper.vm as unknown as {
confirmVsyaRf: () => void;
submit: () => Promise<void>;
form: { name: string; signal_identifier: string };
};
vm.confirmVsyaRf();
vm.form.name = 'Мой проект';
vm.form.signal_identifier = ''; // домен пустой (signal_type='site' по умолчанию)
await vm.submit();
await flushPromises();
expect(apiClient.post).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Введите домен сайта в вашей нише');
});
it.skip('submits valid site project to POST /api/projects', async () => {
// TODO: полная проверка submit требует rendering Vuetify-формы и заполнения
// v-text-field/v-combobox/v-btn-toggle — нестабильно в JSDOM. Покрытие
// делается через Histoire story + e2e (Playwright) после Plan 5 closure.
});
it.skip('emits saved event after successful POST', async () => {
// TODO: см. предыдущий skip — те же причины.
});
it('renders regions autocomplete with 89 selectable subjects (excluding "Вся РФ" sentinel)', async () => {
const wrapper = factory();
await flushPromises();
const autocomplete = wrapper.findComponent({ name: 'VAutocomplete' });
expect(autocomplete.exists()).toBe(true);
expect(autocomplete.props('items')).toHaveLength(89);
expect((autocomplete.props('items') as Array<{ code: number }>).every((r) => r.code !== 0)).toBe(true);
});
it('sends regions array in POST payload', async () => {
const wrapper = factory();
await flushPromises();
const autocomplete = wrapper.findComponent({ name: 'VAutocomplete' });
autocomplete.vm.$emit('update:model-value', [82, 83]);
await flushPromises();
const vm = wrapper.vm as unknown as { form: { name: string; signal_identifier: string } };
vm.form.name = 'Проект';
vm.form.signal_identifier = 'okna-konkurent.ru';
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
await flushPromises();
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
});
it('launch.deferred=1 → открывает диалог перегрузки; проект считается созданным (saved эмитится)', async () => {
// Task 13: POST /api/projects теперь всегда 201.
// Если launch.deferred=1 — проект создан на паузе (баланс), форма закрывается,
// открывается диалог перегрузки. Кнопка save-blocked удалена из диалога.
(apiClient.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: {
data: { id: 42, name: 'Проект' },
launch: {
launched: 0,
deferred: 1,
balance: {
current_balance_rub: '1000.00',
current_capacity_leads: 30,
would_be_required_leads: 50,
deficit_leads: 20,
topup_rub: '600.00',
},
},
},
});
const wrapper = factory();
await flushPromises();
// Проходим гейт обязательного региона через подтверждённую «Вся РФ» + валидные поля.
const vm = wrapper.vm as unknown as {
confirmVsyaRf: () => void;
submit: () => Promise<void>;
form: { name: string; signal_identifier: string };
};
vm.confirmVsyaRf();
vm.form.name = 'Проект';
vm.form.signal_identifier = 'okna-konkurent.ru';
await vm.submit();
await flushPromises();
const overload = wrapper.find('[data-testid="overload-dialog"]');
expect(overload.exists()).toBe(true);
// Диалог показывает сумму из topup_rub (600 ₽) или deficit_leads
expect(overload.text()).toContain('600');
// POST был 1 раз (не 2 — нет повторного submit)
expect(apiClient.post).toHaveBeenCalledTimes(1);
// saved был эмитирован — проект считается созданным
expect(wrapper.emitted('saved')).toBeTruthy();
});
it('показывает баннер «когда пойдут лиды» в режиме создания', async () => {
const wrapper = factory({ modelValue: true, mode: 'create' });
await flushPromises();
const banner = wrapper.find('[data-testid="np-lead-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('Первые лиды пойдут с');
});
it('скрывает баннер в режиме редактирования', async () => {
const wrapper = factory({ modelValue: true, mode: 'edit' });
await flushPromises();
expect(wrapper.find('[data-testid="np-lead-banner"]').exists()).toBe(false);
});
it('M: правка site-проекта не шлёт sms_senders/sms_keyword (без молчаливого 422)', async () => {
const project = {
id: 7,
name: 'Сайт-проект',
signal_type: 'site',
signal_identifier: 'okna.ru',
sms_senders: null,
sms_keyword: null,
daily_limit_target: 50,
delivered_today: 0,
is_active: true,
regions: [77],
delivery_days_mask: 127,
sync_status: 'ok',
} as unknown as Project;
const wrapper = factory({ modelValue: true, mode: 'edit', project });
await flushPromises();
const vm = wrapper.vm as unknown as { submit: () => Promise<void> };
await vm.submit();
await flushPromises();
expect(apiClient.patch).toHaveBeenCalledTimes(1);
const body = (apiClient.patch as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
expect(body).not.toHaveProperty('sms_senders');
expect(body).not.toHaveProperty('sms_keyword');
});
});