Files
portal/app/tests/Frontend/NewProjectDialog.spec.ts
T
Дмитрий 0638c38efc fix/projects: M — правка site/call проекта больше не падает молчаливым 422
Диалог «Редактировать» делал Object.assign(form, project) и слал весь объект,
включая sms_senders=null для site/call. UpdateProjectRequest валидирует
sms_senders как sometimes|array|min:1 → present-null проваливал array → 422.
Поле sms на форме site/call не отрисовано, поэтому errors.sms_senders некуда
показать — диалог «висел молча». Теперь persist() для не-sms проектов не шлёт
sms_senders/sms_keyword (заодно не триггерит зря snapshot-guard).

TDD: NewProjectDialog.spec — правка site не содержит sms-полей в PATCH body.
Глаза: правка site-проекта → PATCH 200, имя изменилось, диалог закрылся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:39:14 +03:00

231 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(() => 'Произошла ошибка.'),
}));
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('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('409 balance_insufficient → открывает диалог перегрузки; save-blocked пере-сабмитит с force_save_blocked', async () => {
// Spec C §6.2 (Task 1.10): первый POST падает 409, второй (после выбора
// «Сохранить и приостановить») уходит с force_save_blocked=true.
(apiClient.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
response: {
status: 409,
data: {
error: 'balance_insufficient',
current_balance_rub: '1000.00',
current_capacity_leads: 30,
would_be_required_leads: 50,
deficit_leads: 20,
},
},
});
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);
expect(overload.text()).toContain('20');
await wrapper.find('[data-testid="overload-save-blocked"]').trigger('click');
await flushPromises();
expect(apiClient.post).toHaveBeenCalledTimes(2);
expect(apiClient.post).toHaveBeenLastCalledWith(
'/api/projects',
expect.objectContaining({ force_save_blocked: true }),
);
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');
});
});