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-цели; стаб делает доступным // внутри корня для 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.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(); 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(); // Проходим гейт обязательного региона через подтверждённую «Вся РФ». (wrapper.vm as unknown as { confirmVsyaRf: () => void }).confirmVsyaRf(); await (wrapper.vm as unknown as { submit: () => Promise }).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(); }); });