import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { createRouter, createMemoryHistory } from 'vue-router' // Mock auth store before any imports vi.mock('../../../stores/auth.js', () => ({ useAuthStore: vi.fn(), })) // Mock api/client to avoid HTTP calls vi.mock('../../../api/client.js', () => ({ changePassword: vi.fn(), totpDisable: vi.fn(), })) import { useAuthStore } from '../../../stores/auth.js' import { changePassword as changePasswordMock, totpDisable as totpDisableMock } from '../../../api/client.js' import SettingsAccountTab from '../SettingsAccountTab.vue' // Minimal router — required because the component calls useRouter() in script setup const testRouter = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '
' } }, { path: '/login', component: { template: '' } }, { path: '/settings', component: { template: '' } }, ], }) const globalStubs = { TotpEnrollment: { template: '' }, ConfirmBlock: { template: '', props: ['message', 'confirmLabel', 'cancelLabel'] }, PasswordStrengthBar: { template: '', props: ['password'] }, AppSpinner: { template: '' }, } beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() }) // ─── GAP 1 & 2: Sessions revoked toast (CR-01, CR-03) ────────────────────── describe('SettingsAccountTab — sessions revoked toast (CR-01, CR-03)', () => { // Stub for ConfirmBlock that renders a button triggering @confirmed emission const ConfirmBlockEmitter = { name: 'ConfirmBlock', template: '', props: ['message', 'confirmLabel', 'cancelLabel'], emits: ['confirmed', 'cancelled'], } it('shows "Other sessions have been terminated." toast after changePassword returns sessions_revoked > 0 (CR-01)', async () => { useAuthStore.mockReturnValue({ user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false }, logoutAll: vi.fn(), }) vi.mocked(changePasswordMock).mockResolvedValueOnce({ message: 'Password updated', sessions_revoked: 2, }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: { ...globalStubs, ConfirmBlock: ConfirmBlockEmitter }, }, }) // Submit the change-password form to invoke changePassword() await wrapper.find('form').trigger('submit') await flushPromises() expect(wrapper.text()).toContain('Other sessions have been terminated.') }) it('does NOT show toast after changePassword when sessions_revoked is 0 (CR-01 negative)', async () => { useAuthStore.mockReturnValue({ user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false }, logoutAll: vi.fn(), }) vi.mocked(changePasswordMock).mockResolvedValueOnce({ message: 'Password updated', sessions_revoked: 0, }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: { ...globalStubs, ConfirmBlock: ConfirmBlockEmitter }, }, }) await wrapper.find('form').trigger('submit') await flushPromises() expect(wrapper.text()).not.toContain('Other sessions have been terminated.') }) it('shows "Other sessions have been terminated." toast after disableTotp returns sessions_revoked > 0 (CR-03)', async () => { const user = { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true } useAuthStore.mockReturnValue({ user, logoutAll: vi.fn(), }) vi.mocked(totpDisableMock).mockResolvedValueOnce({ message: 'TOTP disabled', sessions_revoked: 1, }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: { ...globalStubs, ConfirmBlock: ConfirmBlockEmitter }, }, }) // Click "Disable 2FA" to reveal the ConfirmBlock const disableBtn = wrapper.findAll('button').find(b => b.text().includes('Disable 2FA')) await disableBtn.trigger('click') // Click the confirm button rendered by the ConfirmBlockEmitter stub await wrapper.find('[data-action="confirm"]').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Other sessions have been terminated.') }) }) describe('SettingsAccountTab — four section headings (AUTH-03, AUTH-04)', () => { it('renders all 4 required section headings when totp_enabled is false', () => { useAuthStore.mockReturnValue({ user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false }, logoutAll: vi.fn(), }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: globalStubs, }, }) const text = wrapper.text() expect(text).toContain('Account information') expect(text).toContain('Two-factor authentication') expect(text).toContain('Change password') expect(text).toContain('Sessions') }) it('renders TotpEnrollment stub when totp_enabled is false', () => { useAuthStore.mockReturnValue({ user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false }, logoutAll: vi.fn(), }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: globalStubs, }, }) expect(wrapper.find('[data-stub="TotpEnrollment"]').exists()).toBe(true) }) it('shows "Enabled" status and "Disable 2FA" button when totp_enabled is true', () => { useAuthStore.mockReturnValue({ user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true }, logoutAll: vi.fn(), }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: globalStubs, }, }) // "Enabled" text must appear (inside the 2FA section) expect(wrapper.text()).toContain('Enabled') // "Disable 2FA" button must be present const buttons = wrapper.findAll('button') const disableBtn = buttons.find(b => b.text().includes('Disable 2FA')) expect(disableBtn).toBeDefined() }) it('does NOT render TotpEnrollment stub when totp_enabled is true', () => { useAuthStore.mockReturnValue({ user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true }, logoutAll: vi.fn(), }) const wrapper = mount(SettingsAccountTab, { global: { plugins: [createPinia(), testRouter], stubs: globalStubs, }, }) expect(wrapper.find('[data-stub="TotpEnrollment"]').exists()).toBe(false) }) })