Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
- Introduced a modular tutorial layer to guide new parents through the app setup process. - Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation). - Added just-in-time contextual hints for various features as users encounter them. - Persisted user progress on the backend with new fields in the User model. - Created a new tutorial controller and step registry in the frontend for managing tutorial states. - Added Help button for easy access to tutorial tips and a restart option in the user profile. - Ensured accessibility and mobile responsiveness for the tutorial overlay. - Included tests for backend and frontend functionalities related to the tutorial.
610 lines
17 KiB
TypeScript
610 lines
17 KiB
TypeScript
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'),
|
|
}))
|
|
|
|
function stubUserProfile() {
|
|
return {
|
|
template: '<div class="user-profile"><slot /></div>',
|
|
}
|
|
}
|
|
|
|
function stubModalDialog() {
|
|
return {
|
|
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
|
props: ['show'],
|
|
}
|
|
}
|
|
|
|
function stubImagePicker() {
|
|
return {
|
|
template: '<div class="mock-image-picker" />',
|
|
props: ['modelValue', 'imageType'],
|
|
emits: ['update:modelValue', 'add-image'],
|
|
}
|
|
}
|
|
|
|
function stubToggleField() {
|
|
return {
|
|
template: '<div class="mock-toggle-field" />',
|
|
props: ['label', 'modelValue', 'disabled', 'description', 'error'],
|
|
emits: ['update:modelValue'],
|
|
}
|
|
}
|
|
|
|
function stubProfileSection() {
|
|
return {
|
|
template: '<div class="mock-profile-section"><slot /></div>',
|
|
props: ['title', 'defaultOpen'],
|
|
}
|
|
}
|
|
|
|
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: {
|
|
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<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',
|
|
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<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: {
|
|
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)
|
|
})
|
|
})
|