Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s

This commit is contained in:
2026-04-25 00:40:15 -04:00
parent db846f4e31
commit 127378797c
263 changed files with 88 additions and 79 deletions

View File

@@ -0,0 +1,688 @@
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: '<div />' } },
{ path: '/profile', name: 'UserProfile', component: { template: '<div />' } },
],
})
// 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'),
}))
describe('UserProfile - Delete Account', () => {
let wrapper: VueWrapper<any>
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: {
EntityEditForm: {
template:
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
},
ModalDialog: {
template: '<div class="mock-modal" v-if="show"><slot /></div>',
props: ['show'],
},
},
},
})
})
it('renders Delete My Account button', async () => {
// Wait for component to mount and render
await flushPromises()
await nextTick()
// Test the functionality exists by calling the method directly
expect(wrapper.vm.openDeleteWarning).toBeDefined()
expect(wrapper.vm.confirmDeleteAccount).toBeDefined()
})
it('opens warning modal when Delete My Account button is clicked', async () => {
// Test by calling the method directly
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.initialData.email = 'test@example.com'
// Open warning modal
await wrapper.vm.openDeleteWarning()
await nextTick()
// Find modal delete button (we need to check :disabled binding)
// Since we're using a stub, we'll test the logic directly
wrapper.vm.confirmEmail = 'wrong@example.com'
await nextTick()
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.initialData.email)
wrapper.vm.confirmEmail = 'test@example.com'
await nextTick()
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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.initialData.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 - Profile Update', () => {
let wrapper: VueWrapper<any>
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',
}),
})
// Mount component with router
wrapper = mount(UserProfile, {
global: {
plugins: [mockRouter],
stubs: {
EntityEditForm: {
template: '<div class="mock-form"><slot /></div>',
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
emits: ['submit', 'cancel', 'add-image'],
},
ModalDialog: {
template: '<div class="mock-modal"><slot /></div>',
},
},
},
})
})
it('updates initialData after successful profile save', async () => {
await flushPromises()
await nextTick()
// Initial image_id should be set from mount
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Simulate form submission with new image_id
const newFormData = {
image_id: 'new-image-id',
first_name: 'Updated',
last_name: 'Name',
email: 'test@example.com',
}
await wrapper.vm.handleSubmit(newFormData)
await flushPromises()
// initialData should now be updated to match the saved form
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
expect(wrapper.vm.initialData.first_name).toBe('Updated')
expect(wrapper.vm.initialData.last_name).toBe('Name')
})
it('allows dirty detection after save when reverting to original value', async () => {
await flushPromises()
await nextTick()
// Start with initial-image-id
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Change and save to new-image-id
await wrapper.vm.handleSubmit({
image_id: 'new-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
// initialData should now be new-image-id
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
// Now if user changes back to initial-image-id, it should be detected as different
// (because initialData is now new-image-id)
const currentInitial = wrapper.vm.initialData.image_id
expect(currentInitial).toBe('new-image-id')
expect(currentInitial).not.toBe('initial-image-id')
})
it('handles image upload during profile save', 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.handleSubmit({
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
// Should have called image upload
expect(global.fetch).toHaveBeenCalledWith(
'/api/image/upload',
expect.objectContaining({
method: 'POST',
}),
)
// initialData should be updated with uploaded image ID
expect(wrapper.vm.initialData.image_id).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.handleSubmit({
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
expect(wrapper.vm.loading).toBe(false)
})
it('shows success modal after profile update', async () => {
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
await wrapper.vm.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.showModal).toBe(true)
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
})
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.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
expect(wrapper.vm.loading).toBe(false)
})
})
describe('UserProfile - Notification Toggles', () => {
let wrapper: VueWrapper<any>
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: {
EntityEditForm: {
template:
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
},
ModalDialog: {
template: '<div class="mock-modal" v-if="show"><slot /></div>',
props: ['show'],
},
},
},
})
}
beforeEach(() => {
vi.clearAllMocks()
;(global.fetch as any).mockClear()
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({}),
})
})
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
})
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
wrapper = mountWithDigest(false)
await flushPromises()
await nextTick()
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
})
it('initializes push_enabled to false in initialData when not subscribed', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
expect(wrapper.vm.initialData.push_enabled).toBe(false)
})
it('fields array includes email_digest_enabled as toggle type', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled')
expect(digestField).toBeDefined()
expect(digestField.type).toBe('toggle')
})
it('fields array includes push_enabled as toggle type', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled')
expect(pushField).toBeDefined()
expect(pushField.type).toBe('toggle')
})
it('profile PUT includes email_digest_enabled when changed on submit', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.handleSubmit({
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: false,
push_enabled: 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('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.handleSubmit({
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: true, // same as initial
push_enabled: 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).toBeUndefined()
})
})