import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import { createRouter, createMemoryHistory } from 'vue-router'; // Мокаем api/auth до import'а ForgotPasswordView (внутри useAuthStore). vi.mock('../../resources/js/api/auth', () => ({ login: vi.fn(), register: vi.fn(), me: vi.fn(), logout: vi.fn(), verifyTwoFactor: vi.fn(), forgotPassword: vi.fn(), })); vi.mock('../../resources/js/api/client', () => ({ extractRateLimitRetry: vi.fn(() => null), extractValidationErrors: vi.fn(() => null), extractErrorMessage: vi.fn(() => 'Ошибка'), apiClient: {}, ensureCsrfCookie: vi.fn(), })); import * as authApi from '../../resources/js/api/auth'; import { useAuthStore } from '../../resources/js/stores/auth'; import ForgotPasswordView from '../../resources/js/views/auth/ForgotPasswordView.vue'; const mountForgot = async () => { const pinia = createPinia(); setActivePinia(pinia); const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/forgot', component: ForgotPasswordView }, { path: '/login', name: 'login', component: { template: '
stub
' } }, ], }); await router.push('/forgot'); await router.isReady(); return mount(ForgotPasswordView, { global: { plugins: [pinia, createVuetify(), router] }, }); }; describe('ForgotPasswordView.vue', () => { beforeEach(() => { vi.clearAllMocks(); }); it('монтируется и содержит заголовок «Сброс пароля»', async () => { const wrapper = await mountForgot(); expect(wrapper.text()).toContain('Сброс пароля'); }); it('содержит rate-limit alert с лимитом 5 попыток / 15 минут', async () => { const wrapper = await mountForgot(); const text = wrapper.text(); expect(text).toContain('5 попыток в 15 минут'); }); it('содержит email-input и кнопку «Отправить ссылку»', async () => { const wrapper = await mountForgot(); expect(wrapper.find('input[type="email"]').exists()).toBe(true); expect(wrapper.text()).toContain('Отправить ссылку'); }); it('после успешного submit показывает success-state и скрывает форму', async () => { vi.mocked(authApi.forgotPassword).mockResolvedValue({ message: 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.', }); const wrapper = await mountForgot(); const input = wrapper.find('input[type="email"]'); await input.setValue('user@example.ru'); await wrapper.find('form').trigger('submit.prevent'); // Дожидаемся async submit. await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(wrapper.find('[data-testid="forgot-success"]').exists()).toBe(true); // Форма скрыта после submit. expect(wrapper.find('form').exists()).toBe(false); expect(authApi.forgotPassword).toHaveBeenCalledWith('user@example.ru'); }); it('при 429 показывает lockout-alert через auth.lockoutSeconds', async () => { const wrapper = await mountForgot(); const auth = useAuthStore(); auth.lockoutSeconds = 600; await wrapper.vm.$nextTick(); const alert = wrapper.find('[data-testid="lockout-alert"]'); expect(alert.exists()).toBe(true); expect(alert.text()).toContain('10 мин'); }); it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => { // forgotPassword отклоняется обычной ошибкой; extractValidationErrors и // extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла). vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error')); const wrapper = await mountForgot(); await wrapper.find('input[type="email"]').setValue('user@example.ru'); await wrapper.find('form').trigger('submit.prevent'); await flushPromises(); const messages = wrapper.findAll('.v-messages__message').map((m) => m.text()); expect(messages.join(' ')).toContain('Произошла ошибка. Попробуйте позже.'); }); });