import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import { createRouter, createMemoryHistory } from 'vue-router'; vi.mock('../../resources/js/api/auth', () => ({ login: vi.fn(), register: vi.fn(), me: vi.fn(), logout: vi.fn(), verifyTwoFactor: vi.fn(), forgotPassword: vi.fn(), resetPassword: vi.fn(), useRecoveryCode: 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 UseRecoveryCodeView from '../../resources/js/views/auth/UseRecoveryCodeView.vue'; const mountView = async () => { const pinia = createPinia(); setActivePinia(pinia); // Симулируем pending-2FA state — иначе onMounted редиректит на /login. const auth = useAuthStore(); auth.requires2fa = true; const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/recovery-use', name: 'recovery-use', component: UseRecoveryCodeView }, { path: '/login', name: 'login', component: { template: '
stub
' } }, { path: '/2fa', name: '2fa', component: { template: '
stub
' } }, { path: '/dashboard', name: 'dashboard', component: { template: '
stub
' } }, ], }); await router.push('/recovery-use'); await router.isReady(); return mount(UseRecoveryCodeView, { global: { plugins: [pinia, createVuetify(), router] }, }); }; describe('UseRecoveryCodeView.vue', () => { beforeEach(() => { vi.clearAllMocks(); }); it('монтируется и содержит заголовок «Резервный код»', async () => { const wrapper = await mountView(); expect(wrapper.text()).toContain('Резервный код'); }); it('содержит input с label XXXX-XXXX и autocomplete=one-time-code', async () => { const wrapper = await mountView(); const input = wrapper.find('input'); expect(input.exists()).toBe(true); expect(input.attributes('autocomplete')).toBe('one-time-code'); }); it('успешный submit вызывает auth.useRecoveryCode и сохраняет remaining в sessionStorage', async () => { vi.mocked(authApi.useRecoveryCode).mockResolvedValue({ user: { id: 1, email: 'r@example.ru', first_name: 'R', last_name: 'C', tenant_id: 1, totp_enabled: true, last_login_at: null, }, requires_2fa: false, recovery_codes_remaining: 3, }); const wrapper = await mountView(); await wrapper.find('input').setValue('ABCD-1234'); await wrapper.find('form').trigger('submit.prevent'); await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick(); expect(authApi.useRecoveryCode).toHaveBeenCalledWith('ABCD-1234'); expect(window.sessionStorage.getItem('recovery_codes_remaining')).toBe('3'); }); it('при 429 показывает lockout-alert', async () => { const wrapper = await mountView(); 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 мин'); }); });