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-цели; стаб делает доступным // внутри корня для 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: '
', 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).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; 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; 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).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; 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 }; await vm.submit(); await flushPromises(); expect(apiClient.patch).toHaveBeenCalledTimes(1); const body = (apiClient.patch as ReturnType).mock.calls[0][1] as Record; expect(body).not.toHaveProperty('sms_senders'); expect(body).not.toHaveProperty('sms_keyword'); }); });