Implement account deletion handling and improve user feedback
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Has been cancelled
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Has been cancelled
- Added checks for accounts marked for deletion in signup, verification, and password reset processes. - Updated reward and task listing to sort user-created items first. - Enhanced user API to clear verification and reset tokens when marking accounts for deletion. - Introduced tests for marked accounts to ensure proper handling in various scenarios. - Updated profile and reward edit components to reflect changes in validation and data handling.
This commit is contained in:
@@ -283,3 +283,207 @@ describe('UserProfile - Delete Account', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -165,20 +165,53 @@ function handleRewardModified(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = (task: Task) => {
|
||||
if ('speechSynthesis' in window && task.name) {
|
||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||
window.speechSynthesis.speak(utter)
|
||||
const triggerTask = async (task: Task) => {
|
||||
// Cancel any pending speech to avoid conflicts
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
if (task.name) {
|
||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||
utter.rate = 1.0
|
||||
utter.pitch = 1.0
|
||||
utter.volume = 1.0
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the task via API
|
||||
if (child.value?.id && task.id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: task.id }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to trigger task')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error triggering task:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
const utterString =
|
||||
reward.name +
|
||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
window.speechSynthesis.speak(utter)
|
||||
// Cancel any pending speech to avoid conflicts
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
if (reward.name) {
|
||||
const utterString =
|
||||
reward.name +
|
||||
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
utter.rate = 1.0
|
||||
utter.pitch = 1.0
|
||||
utter.volume = 1.0
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
|
||||
if (reward.redeeming) {
|
||||
dialogReward.value = reward
|
||||
showCancelDialog.value = true
|
||||
@@ -271,6 +304,12 @@ function removeInactivityListeners() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
}
|
||||
|
||||
const readyItemId = ref<string | null>(null)
|
||||
|
||||
function handleItemReady(itemId: string) {
|
||||
readyItemId.value = itemId
|
||||
}
|
||||
|
||||
const hasPendingRewards = computed(() =>
|
||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||
)
|
||||
@@ -333,6 +372,9 @@ onUnmounted(() => {
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
:isParentAuthenticated="false"
|
||||
:readyItemId="readyItemId"
|
||||
@item-ready="handleItemReady"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
@@ -364,6 +406,9 @@ onUnmounted(() => {
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
:isParentAuthenticated="false"
|
||||
:readyItemId="readyItemId"
|
||||
@item-ready="handleItemReady"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
@@ -394,6 +439,9 @@ onUnmounted(() => {
|
||||
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||
itemKey="reward_status"
|
||||
imageField="image_id"
|
||||
:isParentAuthenticated="false"
|
||||
:readyItemId="readyItemId"
|
||||
@item-ready="handleItemReady"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="
|
||||
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('ChildView', () => {
|
||||
// Mock speech synthesis
|
||||
global.window.speechSynthesis = {
|
||||
speak: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
} as any
|
||||
global.window.SpeechSynthesisUtterance = vi.fn() as any
|
||||
})
|
||||
@@ -186,13 +187,18 @@ describe('ChildView', () => {
|
||||
it('speaks task name when triggered', () => {
|
||||
wrapper.vm.triggerTask(mockChore)
|
||||
|
||||
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
|
||||
expect(window.speechSynthesis.speak).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not crash if speechSynthesis is not available', () => {
|
||||
const originalSpeechSynthesis = global.window.speechSynthesis
|
||||
delete (global.window as any).speechSynthesis
|
||||
|
||||
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
|
||||
|
||||
// Restore for other tests
|
||||
global.window.speechSynthesis = originalSpeechSynthesis
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,4 +315,95 @@ describe('ChildView', () => {
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Item Ready State Management', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ChildView)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('initializes readyItemId to null', () => {
|
||||
expect(wrapper.vm.readyItemId).toBe(null)
|
||||
})
|
||||
|
||||
it('updates readyItemId when handleItemReady is called with an item ID', () => {
|
||||
wrapper.vm.handleItemReady('task-1')
|
||||
expect(wrapper.vm.readyItemId).toBe('task-1')
|
||||
|
||||
wrapper.vm.handleItemReady('reward-2')
|
||||
expect(wrapper.vm.readyItemId).toBe('reward-2')
|
||||
})
|
||||
|
||||
it('clears readyItemId when handleItemReady is called with empty string', () => {
|
||||
wrapper.vm.readyItemId = 'task-1'
|
||||
wrapper.vm.handleItemReady('')
|
||||
expect(wrapper.vm.readyItemId).toBe('')
|
||||
})
|
||||
|
||||
it('passes readyItemId prop to Chores ScrollingList', async () => {
|
||||
wrapper.vm.readyItemId = 'task-1'
|
||||
await nextTick()
|
||||
|
||||
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
|
||||
expect(choresScrollingList.props('readyItemId')).toBe('task-1')
|
||||
})
|
||||
|
||||
it('passes readyItemId prop to Penalties ScrollingList', async () => {
|
||||
wrapper.vm.readyItemId = 'task-2'
|
||||
await nextTick()
|
||||
|
||||
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
|
||||
expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2')
|
||||
})
|
||||
|
||||
it('passes readyItemId prop to Rewards ScrollingList', async () => {
|
||||
wrapper.vm.readyItemId = 'reward-1'
|
||||
await nextTick()
|
||||
|
||||
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
|
||||
expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1')
|
||||
})
|
||||
|
||||
it('handles item-ready event from Chores ScrollingList', async () => {
|
||||
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
|
||||
|
||||
choresScrollingList.vm.$emit('item-ready', 'task-1')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.readyItemId).toBe('task-1')
|
||||
})
|
||||
|
||||
it('handles item-ready event from Penalties ScrollingList', async () => {
|
||||
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
|
||||
|
||||
penaltiesScrollingList.vm.$emit('item-ready', 'task-2')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.readyItemId).toBe('task-2')
|
||||
})
|
||||
|
||||
it('handles item-ready event from Rewards ScrollingList', async () => {
|
||||
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
|
||||
|
||||
rewardsScrollingList.vm.$emit('item-ready', 'reward-1')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.readyItemId).toBe('reward-1')
|
||||
})
|
||||
|
||||
it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => {
|
||||
// Initial state
|
||||
expect(wrapper.vm.readyItemId).toBe(null)
|
||||
|
||||
// First click - item should become ready
|
||||
wrapper.vm.handleItemReady('task-1')
|
||||
expect(wrapper.vm.readyItemId).toBe('task-1')
|
||||
|
||||
// Second click would trigger the item (tested via ScrollingList component)
|
||||
// After trigger, ready state should be cleared
|
||||
wrapper.vm.handleItemReady('')
|
||||
expect(wrapper.vm.readyItemId).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -231,6 +231,8 @@ async function updateProfile(form: {
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
// Update initialData to reflect the saved state
|
||||
initialData.value = { ...form }
|
||||
modalTitle.value = 'Profile Updated'
|
||||
modalSubtitle.value = ''
|
||||
modalMessage.value = 'Your profile was updated successfully.'
|
||||
@@ -245,7 +247,11 @@ async function updateProfile(form: {
|
||||
}
|
||||
|
||||
async function handlePasswordModalClose() {
|
||||
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
|
||||
showModal.value = false
|
||||
if (wasProfileUpdate) {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
|
||||
@@ -36,7 +36,7 @@ const fields: {
|
||||
}[] = [
|
||||
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
|
||||
{ name: 'description', label: 'Description', type: 'text', maxlength: 128 },
|
||||
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 1000 },
|
||||
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 10000 },
|
||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
|
||||
]
|
||||
// removed duplicate defineProps
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import '@/assets/styles.css'
|
||||
@@ -120,9 +120,33 @@ function checkDirty() {
|
||||
})
|
||||
}
|
||||
|
||||
// Validation logic
|
||||
const isValid = computed(() => {
|
||||
return props.fields.every((field) => {
|
||||
if (!field.required) return true
|
||||
const value = formData.value[field.name]
|
||||
|
||||
if (field.type === 'text') {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const numValue = Number(value)
|
||||
if (isNaN(numValue)) return false
|
||||
if (field.min !== undefined && numValue < field.min) return false
|
||||
if (field.max !== undefined && numValue > field.max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// For other types, just check it's not null/undefined
|
||||
return value != null
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({ ...formData.value }),
|
||||
() => {
|
||||
(newVal) => {
|
||||
console.log('formData changed:', newVal)
|
||||
checkDirty()
|
||||
},
|
||||
{ deep: true },
|
||||
|
||||
@@ -73,7 +73,7 @@ const fields: {
|
||||
imageType?: number
|
||||
}[] = [
|
||||
{ name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 },
|
||||
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 100 },
|
||||
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 1000 },
|
||||
{ name: 'is_good', label: 'Task Type', type: 'custom' },
|
||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user