diff --git a/frontend/src/components/admin/user/GroupReplaceModal.vue b/frontend/src/components/admin/user/GroupReplaceModal.vue index 429826ba3b..3a34274cae 100644 --- a/frontend/src/components/admin/user/GroupReplaceModal.vue +++ b/frontend/src/components/admin/user/GroupReplaceModal.vue @@ -80,6 +80,7 @@ import { ref, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import { extractApiErrorMessage } from '@/utils/apiError' import type { AdminUser, AdminGroup } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Icon from '@/components/icons/Icon.vue' @@ -123,6 +124,7 @@ const handleReplace = async () => { emit('success') emit('close') } catch (error) { + appStore.showError(extractApiErrorMessage(error, t('common.error'))) console.error('Failed to replace group:', error) } finally { submitting.value = false diff --git a/frontend/src/components/admin/user/__tests__/GroupReplaceModal.spec.ts b/frontend/src/components/admin/user/__tests__/GroupReplaceModal.spec.ts new file mode 100644 index 0000000000..ab7e3b5704 --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/GroupReplaceModal.spec.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import type { AdminGroup, AdminUser } from '@/types' +import GroupReplaceModal from '../GroupReplaceModal.vue' + +const mocks = vi.hoisted(() => ({ replaceGroup: vi.fn(), showError: vi.fn(), showSuccess: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { users: { replaceGroup: mocks.replaceGroup } } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => mocks })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +afterEach(() => vi.restoreAllMocks()) +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +async function selectGroup() { + const wrapper = mount(GroupReplaceModal, { + props: { + show: true, user: { id: 10 } as AdminUser, oldGroup: { id: 1, name: 'Old' }, + allGroups: [{ id: 2, name: 'New', status: 'active', is_exclusive: true, subscription_type: 'standard' } as AdminGroup] + }, + global: { stubs: { + BaseDialog: { props: ['show'], template: '
' }, Icon: true + } } + }) + await wrapper.get('input[type="radio"]').setValue() + return wrapper +} + +describe('GroupReplaceModal feedback', () => { + it.each([ + [{ message: 'Group is unavailable' }, 'Group is unavailable'], + [{ response: { data: { detail: 'Permission denied' } } }, 'Permission denied'], + [{}, 'common.error'] + ])('shows API errors and keeps the dialog available for retry', async (error, message) => { + mocks.replaceGroup.mockRejectedValueOnce(error) + const wrapper = await selectGroup() + await wrapper.get('button.btn-primary').trigger('click') + await flushPromises() + expect(mocks.showError).toHaveBeenCalledWith(message) + expect(wrapper.emitted('success')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + expect(wrapper.get('button.btn-primary').attributes('disabled')).toBeUndefined() + }) + + it('still reports successful migrations and closes', async () => { + mocks.replaceGroup.mockResolvedValueOnce({ migrated_keys: 3 }) + const wrapper = await selectGroup() + await wrapper.get('button.btn-primary').trigger('click') + await flushPromises() + expect(mocks.replaceGroup).toHaveBeenCalledWith(10, 1, 2) + expect(mocks.showSuccess).toHaveBeenCalledWith('admin.users.replaceGroupSuccess') + expect(mocks.showError).not.toHaveBeenCalled() + expect(wrapper.emitted('success')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) +})