import { describe, it, expect, beforeEach, vi } from 'vitest' import { mount, VueWrapper, flushPromises } from '@vue/test-utils' import { nextTick } from 'vue' import UserProfile from '../components/profile/UserProfile.vue' import { createMemoryHistory, createRouter } from 'vue-router' // Mock fetch globally global.fetch = vi.fn() // Mock router const mockRouter = createRouter({ history: createMemoryHistory(), routes: [ { path: '/auth/login', name: 'Login', component: { template: '
' } }, { path: '/profile', name: 'UserProfile', component: { template: '
' } }, ], }) // Mock auth store const { mockLogoutUser, mockSuppressForceLogout } = vi.hoisted(() => ({ mockLogoutUser: vi.fn(), mockSuppressForceLogout: { value: false }, })) vi.mock('../stores/auth', () => ({ logoutUser: () => mockLogoutUser(), suppressForceLogout: mockSuppressForceLogout, })) // Mock push subscription service vi.mock('../services/pushSubscription', () => ({ isSubscribedToPush: vi.fn().mockResolvedValue(false), subscribeToPushWithResult: vi.fn().mockResolvedValue({ ok: true }), unsubscribeFromPush: vi.fn().mockResolvedValue(undefined), getPushPermissionState: vi.fn().mockReturnValue('default'), })) function stubUserProfile() { return { template: '', } } function stubModalDialog() { return { template: '
', props: ['show'], } } function stubImagePicker() { return { template: '
', props: ['modelValue', 'imageType'], emits: ['update:modelValue', 'add-image'], } } function stubToggleField() { return { template: '
', props: ['label', 'modelValue', 'disabled', 'description', 'error'], emits: ['update:modelValue'], } } function stubProfileSection() { return { template: '
', props: ['title', 'defaultOpen'], } } describe('UserProfile - Delete Account', () => { let wrapper: VueWrapper beforeEach(() => { vi.clearAllMocks() ;(global.fetch as any).mockClear() // Mock fetch for profile loading in onMounted ;(global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({ image_id: null, first_name: 'Test', last_name: 'User', email: 'test@example.com', }), }) // Mount component with router wrapper = mount(UserProfile, { global: { plugins: [mockRouter], stubs: { ProfileSection: stubProfileSection(), ImagePicker: stubImagePicker(), ToggleField: stubToggleField(), ModalDialog: stubModalDialog(), }, }, }) }) it('renders Delete My Account button', async () => { await flushPromises() await nextTick() expect(wrapper.vm.openDeleteWarning).toBeDefined() expect(wrapper.vm.confirmDeleteAccount).toBeDefined() }) it('opens warning modal when Delete My Account button is clicked', async () => { wrapper.vm.openDeleteWarning() await nextTick() expect(wrapper.vm.showDeleteWarning).toBe(true) expect(wrapper.vm.confirmEmail).toBe('') }) it('Delete button in warning modal is disabled until email matches', async () => { // Set initial email wrapper.vm.email = 'test@example.com' // Open warning modal await wrapper.vm.openDeleteWarning() await nextTick() wrapper.vm.confirmEmail = 'wrong@example.com' await nextTick() expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.email) wrapper.vm.confirmEmail = 'test@example.com' await nextTick() expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.email) }) it('calls API when confirmed with correct email', async () => { const mockResponse = { ok: true, json: async () => ({ success: true }), } ;(global.fetch as any).mockResolvedValueOnce(mockResponse) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(global.fetch).toHaveBeenCalledWith( '/api/user/mark-for-deletion', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@example.com' }), }), ) }) it('does not call API if email is invalid format', async () => { wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'invalid-email' await wrapper.vm.confirmDeleteAccount() // Only the profile fetch should have been called, not mark-for-deletion expect(global.fetch).toHaveBeenCalledTimes(1) expect(global.fetch).toHaveBeenCalledWith('/api/user/profile') }) it('shows success modal after successful API response', async () => { const mockResponse = { ok: true, json: async () => ({ success: true }), } ;(global.fetch as any).mockResolvedValueOnce(mockResponse) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(wrapper.vm.showDeleteWarning).toBe(false) expect(wrapper.vm.showDeleteSuccess).toBe(true) }) it('sets suppressForceLogout before making the API call', async () => { let flagDuringRequest = false ;(global.fetch as any).mockImplementationOnce(async () => { flagDuringRequest = mockSuppressForceLogout.value return { ok: true, json: async () => ({ success: true }) } }) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() expect(flagDuringRequest).toBe(true) }) it('leaves suppressForceLogout set after successful deletion', async () => { ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }), }) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(mockSuppressForceLogout.value).toBe(true) }) it('clears suppressForceLogout on API failure', async () => { ;(global.fetch as any).mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ error: 'fail', code: 'ERROR' }), }) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(mockSuppressForceLogout.value).toBe(false) }) it('clears suppressForceLogout on network error', async () => { ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error')) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(mockSuppressForceLogout.value).toBe(false) }) it('shows error modal on API failure', async () => { const mockResponse = { ok: false, status: 400, json: async () => ({ error: 'Account already marked', code: 'ALREADY_MARKED', }), } ;(global.fetch as any).mockResolvedValueOnce(mockResponse) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(wrapper.vm.showDeleteWarning).toBe(false) expect(wrapper.vm.showDeleteError).toBe(true) expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.') }) it('shows error modal on network error', async () => { ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error')) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(wrapper.vm.showDeleteWarning).toBe(false) expect(wrapper.vm.showDeleteError).toBe(true) expect(wrapper.vm.deleteErrorMessage).toBe('Network error. Please try again.') }) it('signs out user after success modal OK button', async () => { wrapper.vm.showDeleteSuccess = true await wrapper.vm.handleDeleteSuccess() await nextTick() expect(wrapper.vm.showDeleteSuccess).toBe(false) expect(mockLogoutUser).toHaveBeenCalled() }) it('redirects to landing page after sign-out', async () => { const pushSpy = vi.spyOn(mockRouter, 'push') wrapper.vm.showDeleteSuccess = true await wrapper.vm.handleDeleteSuccess() await nextTick() expect(pushSpy).toHaveBeenCalledWith('/') }) it('closes error modal when Close button is clicked', async () => { wrapper.vm.showDeleteError = true wrapper.vm.deleteErrorMessage = 'Some error' await wrapper.vm.closeDeleteError() await nextTick() expect(wrapper.vm.showDeleteError).toBe(false) expect(wrapper.vm.deleteErrorMessage).toBe('') }) it('closes warning modal when cancelled', async () => { wrapper.vm.showDeleteWarning = true wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.closeDeleteWarning() await nextTick() expect(wrapper.vm.showDeleteWarning).toBe(false) expect(wrapper.vm.confirmEmail).toBe('') }) it('disables button during loading', async () => { const mockResponse = { ok: true, json: async () => ({ success: true }), } ;(global.fetch as any).mockImplementation( () => new Promise((resolve) => { setTimeout(() => resolve(mockResponse), 100) }), ) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' const deletePromise = wrapper.vm.confirmDeleteAccount() await nextTick() // Check loading state is true during API call expect(wrapper.vm.deletingAccount).toBe(true) await deletePromise await nextTick() // Check loading state is false after API call expect(wrapper.vm.deletingAccount).toBe(false) }) it('resets confirmEmail when opening warning modal', async () => { wrapper.vm.confirmEmail = 'old@example.com' await wrapper.vm.openDeleteWarning() await nextTick() expect(wrapper.vm.confirmEmail).toBe('') expect(wrapper.vm.showDeleteWarning).toBe(true) }) it('handles ALREADY_MARKED error code correctly', async () => { const mockResponse = { ok: false, status: 400, json: async () => ({ error: 'Already marked', code: 'ALREADY_MARKED', }), } ;(global.fetch as any).mockResolvedValueOnce(mockResponse) wrapper.vm.email = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com' await wrapper.vm.confirmDeleteAccount() await nextTick() expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.') }) }) describe('UserProfile - Auto-save', () => { let wrapper: VueWrapper beforeEach(() => { vi.clearAllMocks() ;(global.fetch as any).mockClear() // Mock fetch for profile loading in onMounted ;(global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({ image_id: 'initial-image-id', first_name: 'Test', last_name: 'User', email: 'test@example.com', email_digest_enabled: true, }), }) wrapper = mount(UserProfile, { global: { plugins: [mockRouter], stubs: { ProfileSection: stubProfileSection(), ImagePicker: stubImagePicker(), ToggleField: stubToggleField(), ModalDialog: stubModalDialog(), }, }, }) }) it('saveNames sends PUT with first and last name', async () => { await flushPromises() await nextTick() ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) wrapper.vm.firstName = 'Updated' wrapper.vm.lastName = 'Name' await wrapper.vm.saveNames() await flushPromises() const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') expect(putCall).toBeDefined() const body = JSON.parse(putCall[1].body) expect(body.first_name).toBe('Updated') expect(body.last_name).toBe('Name') }) it('saveImage sends PUT with image_id', async () => { await flushPromises() await nextTick() ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) await wrapper.vm.saveImage('new-image-id') await flushPromises() const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') expect(putCall).toBeDefined() const body = JSON.parse(putCall[1].body) expect(body.image_id).toBe('new-image-id') }) it('uploadLocalImage uploads file then saves image_id', async () => { await flushPromises() await nextTick() const mockFile = new File(['test'], 'test.png', { type: 'image/png' }) wrapper.vm.localImageFile = mockFile // Mock image upload response ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'uploaded-image-id' }), }) // Mock profile update response ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}), }) await wrapper.vm.uploadLocalImage() await flushPromises() // Should have called image upload const uploadCall = (global.fetch as any).mock.calls.find( (c: any[]) => c[0] === '/api/image/upload', ) expect(uploadCall).toBeDefined() expect(uploadCall[1].method).toBe('POST') // imageId should be updated expect(wrapper.vm.imageId).toBe('uploaded-image-id') }) it('shows error message on failed image upload', async () => { await flushPromises() await nextTick() const mockFile = new File(['test'], 'test.png', { type: 'image/png' }) wrapper.vm.localImageFile = mockFile // Mock failed image upload ;(global.fetch as any).mockResolvedValueOnce({ ok: false, status: 500, }) await wrapper.vm.uploadLocalImage() await flushPromises() expect(wrapper.vm.errorMsg).toBe('Failed to upload image.') }) it('shows error message on failed profile update', async () => { await flushPromises() await nextTick() ;(global.fetch as any).mockResolvedValueOnce({ ok: false, status: 500, }) await wrapper.vm.saveNames() await flushPromises() expect(wrapper.vm.errorMsg).toBe('Failed to update profile.') }) }) describe('UserProfile - Notification Toggles', () => { let wrapper: VueWrapper function mountWithDigest(emailDigestEnabled: boolean) { ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({ image_id: null, first_name: 'Test', last_name: 'User', email: 'test@example.com', email_digest_enabled: emailDigestEnabled, }), }) return mount(UserProfile, { global: { plugins: [mockRouter], stubs: { ProfileSection: stubProfileSection(), ImagePicker: stubImagePicker(), ToggleField: stubToggleField(), ModalDialog: stubModalDialog(), }, }, }) } beforeEach(() => { vi.clearAllMocks() ;(global.fetch as any).mockClear() ;(global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({}), }) }) it('initializes emailDigestEnabled to true when profile returns true', async () => { wrapper = mountWithDigest(true) await flushPromises() await nextTick() expect(wrapper.vm.emailDigestEnabled).toBe(true) }) it('initializes emailDigestEnabled to false when profile returns false', async () => { wrapper = mountWithDigest(false) await flushPromises() await nextTick() expect(wrapper.vm.emailDigestEnabled).toBe(false) }) it('initializes pushEnabled to false when not subscribed', async () => { wrapper = mountWithDigest(true) await flushPromises() await nextTick() expect(wrapper.vm.pushEnabled).toBe(false) }) it('onToggleDigest sends PUT with new value', async () => { wrapper = mountWithDigest(true) await flushPromises() await nextTick() ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) await wrapper.vm.onToggleDigest(false) await flushPromises() const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') expect(putCall).toBeDefined() const body = JSON.parse(putCall[1].body) expect(body.email_digest_enabled).toBe(false) }) it('onTogglePush sends PUT with new value and applies push change', async () => { wrapper = mountWithDigest(true) await flushPromises() await nextTick() ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) expect(wrapper.vm.pushEnabled).toBe(false) await wrapper.vm.onTogglePush(true) await flushPromises() const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') expect(putCall).toBeDefined() const body = JSON.parse(putCall[1].body) expect(body.push_notifications_enabled).toBe(true) }) })