mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 16:20:27 +00:00
Merge pull request #4434 from yan9651688/feat/group-one-click-copy
feat(group): add safe one-click duplication
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post }
|
||||
}))
|
||||
|
||||
import { duplicate } from '@/api/admin/groups'
|
||||
|
||||
describe('admin group duplicate API', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } })
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends a stable idempotency key with the duplicate request', async () => {
|
||||
const group = await duplicate(42)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, {
|
||||
headers: {
|
||||
'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' })
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after an ambiguous failed request', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
||||
|
||||
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
||||
await duplicate(99)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after a page reload', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(77)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.resetModules()
|
||||
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
|
||||
const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups')
|
||||
await duplicateAfterReload(77)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not reuse an operation key across administrators for the same group', async () => {
|
||||
post.mockRejectedValueOnce(new Error('first admin timeout'))
|
||||
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
|
||||
const firstAdminHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'22222222-2222-4222-8222-222222222222'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
|
||||
await duplicate(55)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
|
||||
expect(post.mock.calls[1][2].headers).toEqual({
|
||||
'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe(
|
||||
firstAdminHeaders['Idempotency-Key']
|
||||
)
|
||||
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
|
||||
localStorage.setItem('auth_user', '{invalid json')
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(66)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
|
||||
await duplicate(66)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise<AdminGroup>
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a group on the server so configuration that is not present in the
|
||||
* list response is preserved. Keep the operation key after ambiguous failures
|
||||
* so a retry replays the original operation instead of creating another group.
|
||||
*/
|
||||
const duplicateOperationKeys = new Map<string, string>()
|
||||
|
||||
interface DuplicateOperationScope {
|
||||
adminID: string
|
||||
key: string
|
||||
}
|
||||
|
||||
function getCurrentAdminID(): string | null {
|
||||
try {
|
||||
const rawUser = globalThis.localStorage?.getItem('auth_user')
|
||||
if (!rawUser) return null
|
||||
|
||||
const user: unknown = JSON.parse(rawUser)
|
||||
if (typeof user !== 'object' || user === null) return null
|
||||
|
||||
const id = (user as { id?: unknown }).id
|
||||
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
|
||||
return String(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
|
||||
const adminID = getCurrentAdminID()
|
||||
if (!adminID) return null
|
||||
|
||||
return {
|
||||
adminID,
|
||||
key: `sub2api:admin:group-duplicate:${adminID}:${id}`
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredDuplicateOperationKey(storageKey: string): string | null {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(storageKey) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
|
||||
try {
|
||||
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
|
||||
else globalThis.sessionStorage?.removeItem(storageKey)
|
||||
} catch {
|
||||
// In-memory retry protection still works when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicate(id: number): Promise<AdminGroup> {
|
||||
const scope = duplicateOperationScope(id)
|
||||
let idempotencyKey = scope
|
||||
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
|
||||
: null
|
||||
if (!idempotencyKey) {
|
||||
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
|
||||
}
|
||||
if (scope) {
|
||||
duplicateOperationKeys.set(scope.key, idempotencyKey)
|
||||
storeDuplicateOperationKey(scope.key, idempotencyKey)
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<AdminGroup>(`/admin/groups/${id}/duplicate`, undefined, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey }
|
||||
})
|
||||
|
||||
if (scope) {
|
||||
duplicateOperationKeys.delete(scope.key)
|
||||
storeDuplicateOperationKey(scope.key, null)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group
|
||||
* @param id - Group ID
|
||||
@@ -337,6 +417,7 @@ export const groupsAPI = {
|
||||
getById,
|
||||
getModelsListCandidates,
|
||||
create,
|
||||
duplicate,
|
||||
update,
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
|
||||
@@ -743,6 +743,10 @@ export default {
|
||||
createGroup: 'Create Group',
|
||||
editGroup: 'Edit Group',
|
||||
deleteGroup: 'Delete Group',
|
||||
duplicate: 'Duplicate',
|
||||
duplicating: 'Duplicating',
|
||||
duplicateSuccess: 'Group duplicated as "{name}" and disabled. Review its configuration before enabling it.',
|
||||
duplicateFailed: 'Failed to duplicate group',
|
||||
sortOrder: 'Sort',
|
||||
columnSettings: 'Column Settings',
|
||||
sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first',
|
||||
|
||||
@@ -740,6 +740,10 @@ export default {
|
||||
createGroup: '创建分组',
|
||||
editGroup: '编辑分组',
|
||||
deleteGroup: '删除分组',
|
||||
duplicate: '复制',
|
||||
duplicating: '复制中',
|
||||
duplicateSuccess: '分组已复制为「{name}」,已默认停用,请确认配置后再启用',
|
||||
duplicateFailed: '复制分组失败',
|
||||
sortOrder: '排序',
|
||||
columnSettings: '列设置',
|
||||
sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示',
|
||||
|
||||
@@ -366,6 +366,26 @@
|
||||
<Icon name="edit" size="sm" />
|
||||
<span class="text-xs">{{ t("common.edit") }}</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="group-duplicate"
|
||||
:title="
|
||||
duplicatingGroupIds.has(row.id)
|
||||
? t('admin.groups.duplicating')
|
||||
: t('admin.groups.duplicate')
|
||||
"
|
||||
:disabled="duplicatingGroupIds.has(row.id)"
|
||||
@click="handleDuplicate(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||
>
|
||||
<Icon name="copy" size="sm" />
|
||||
<span class="text-xs">
|
||||
{{
|
||||
duplicatingGroupIds.has(row.id)
|
||||
? t("admin.groups.duplicating")
|
||||
: t("admin.groups.duplicate")
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRateMultipliers(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400"
|
||||
@@ -3583,6 +3603,7 @@ import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesMo
|
||||
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
|
||||
import { extractApiErrorMessage } from "@/utils/apiError";
|
||||
import { useKeyedDebouncedSearch } from "@/composables/useKeyedDebouncedSearch";
|
||||
import { getPersistedPageSize } from "@/composables/usePersistedPageSize";
|
||||
import {
|
||||
@@ -3967,6 +3988,7 @@ const submitting = ref(false);
|
||||
const sortSubmitting = ref(false);
|
||||
const editingGroup = ref<AdminGroup | null>(null);
|
||||
const deletingGroup = ref<AdminGroup | null>(null);
|
||||
const duplicatingGroupIds = reactive(new Set<number>());
|
||||
const showRateMultipliersModal = ref(false);
|
||||
const rateMultipliersGroup = ref<AdminGroup | null>(null);
|
||||
const showRPMOverridesModal = ref(false);
|
||||
@@ -5126,6 +5148,25 @@ const handleRPMOverrides = (group: AdminGroup) => {
|
||||
showRPMOverridesModal.value = true;
|
||||
};
|
||||
|
||||
const handleDuplicate = async (group: AdminGroup) => {
|
||||
if (duplicatingGroupIds.has(group.id)) return;
|
||||
|
||||
duplicatingGroupIds.add(group.id);
|
||||
try {
|
||||
const duplicate = await adminAPI.groups.duplicate(group.id);
|
||||
appStore.showSuccess(
|
||||
t("admin.groups.duplicateSuccess", { name: duplicate.name }),
|
||||
);
|
||||
await loadGroups();
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(
|
||||
extractApiErrorMessage(error, t("admin.groups.duplicateFailed")),
|
||||
);
|
||||
} finally {
|
||||
duplicatingGroupIds.delete(group.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group;
|
||||
showDeleteDialog.value = true;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AdminGroup } from '@/types'
|
||||
import GroupsView from '@/views/admin/GroupsView.vue'
|
||||
|
||||
const {
|
||||
listGroups,
|
||||
duplicateGroup,
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
showSuccess,
|
||||
showError
|
||||
} = vi.hoisted(() => ({
|
||||
listGroups: vi.fn(),
|
||||
duplicateGroup: vi.fn(),
|
||||
getModelsListCandidates: vi.fn(),
|
||||
getUsageSummary: vi.fn(),
|
||||
getCapacitySummary: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
groups: {
|
||||
list: listGroups,
|
||||
duplicate: duplicateGroup,
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
getAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
updateSortOrder: vi.fn()
|
||||
},
|
||||
accounts: {
|
||||
list: vi.fn(),
|
||||
getById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ showSuccess, showError })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/onboarding', () => ({
|
||||
useOnboardingStore: () => ({
|
||||
isCurrentStep: vi.fn(() => false),
|
||||
nextStep: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
const sourceGroup: AdminGroup = {
|
||||
id: 42,
|
||||
name: 'Primary',
|
||||
description: null,
|
||||
platform: 'openai',
|
||||
rate_multiplier: 1,
|
||||
rpm_limit: 0,
|
||||
is_exclusive: false,
|
||||
status: 'active',
|
||||
subscription_type: 'standard',
|
||||
daily_limit_usd: null,
|
||||
weekly_limit_usd: null,
|
||||
monthly_limit_usd: null,
|
||||
allow_image_generation: false,
|
||||
allow_batch_image_generation: false,
|
||||
image_rate_independent: false,
|
||||
image_rate_multiplier: 1,
|
||||
batch_image_discount_multiplier: 0.5,
|
||||
batch_image_hold_multiplier: 0.6,
|
||||
image_price_1k: null,
|
||||
image_price_2k: null,
|
||||
image_price_4k: null,
|
||||
video_rate_independent: false,
|
||||
video_rate_multiplier: 1,
|
||||
video_price_480p: null,
|
||||
video_price_720p: null,
|
||||
video_price_1080p: null,
|
||||
web_search_price_per_call: null,
|
||||
peak_rate_enabled: false,
|
||||
peak_start: '',
|
||||
peak_end: '',
|
||||
peak_rate_multiplier: 1,
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null,
|
||||
fallback_group_id_on_invalid_request: null,
|
||||
allow_messages_dispatch: false,
|
||||
default_mapped_model: '',
|
||||
messages_dispatch_model_config: undefined,
|
||||
require_oauth_only: false,
|
||||
require_privacy_set: false,
|
||||
created_at: '2026-07-16T00:00:00Z',
|
||||
updated_at: '2026-07-16T00:00:00Z',
|
||||
model_routing: null,
|
||||
model_routing_enabled: false,
|
||||
mcp_xml_inject: true,
|
||||
supported_model_scopes: [],
|
||||
account_count: 1,
|
||||
active_account_count: 1,
|
||||
rate_limited_account_count: 0,
|
||||
models_list_config: undefined,
|
||||
sort_order: 10
|
||||
}
|
||||
|
||||
const AppLayoutStub = defineComponent({
|
||||
template: '<main><slot /></main>'
|
||||
})
|
||||
|
||||
const TablePageLayoutStub = defineComponent({
|
||||
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>'
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
props: {
|
||||
data: { type: Array, default: () => [] },
|
||||
columns: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false }
|
||||
},
|
||||
template: '<div><div v-for="row in data" :key="row.id"><slot name="cell-actions" :row="row" /></div></div>'
|
||||
})
|
||||
|
||||
function mountView() {
|
||||
return mount(GroupsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
TablePageLayout: TablePageLayoutStub,
|
||||
DataTable: DataTableStub,
|
||||
Pagination: true,
|
||||
BaseDialog: true,
|
||||
ConfirmDialog: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
PlatformIcon: true,
|
||||
Icon: true,
|
||||
GroupCapacityBadge: true,
|
||||
GroupRateMultipliersModal: true,
|
||||
GroupRPMOverridesModal: true,
|
||||
VueDraggable: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('GroupsView duplicate action', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
for (const fn of [
|
||||
listGroups,
|
||||
duplicateGroup,
|
||||
getModelsListCandidates,
|
||||
getUsageSummary,
|
||||
getCapacitySummary,
|
||||
showSuccess,
|
||||
showError
|
||||
]) {
|
||||
fn.mockReset()
|
||||
}
|
||||
|
||||
listGroups.mockResolvedValue({
|
||||
items: [sourceGroup],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1
|
||||
})
|
||||
duplicateGroup.mockResolvedValue({
|
||||
...sourceGroup,
|
||||
id: 43,
|
||||
name: 'Primary (Copy)',
|
||||
status: 'inactive'
|
||||
})
|
||||
getModelsListCandidates.mockResolvedValue([])
|
||||
getUsageSummary.mockResolvedValue([])
|
||||
getCapacitySummary.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('duplicates the selected group, reports success, and refreshes the list', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(duplicateGroup).toHaveBeenCalledTimes(1)
|
||||
expect(duplicateGroup).toHaveBeenCalledWith(42)
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.groups.duplicateSuccess')
|
||||
expect(listGroups).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores repeated clicks while the duplicate request is in flight', async () => {
|
||||
let resolveDuplicate!: (value: AdminGroup) => void
|
||||
duplicateGroup.mockImplementationOnce(
|
||||
() => new Promise<AdminGroup>((resolve) => { resolveDuplicate = resolve })
|
||||
)
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.get('[data-testid="group-duplicate"]')
|
||||
void button.trigger('click')
|
||||
void button.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(duplicateGroup).toHaveBeenCalledTimes(1)
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(button.attributes('title')).toBe('admin.groups.duplicating')
|
||||
|
||||
resolveDuplicate({ ...sourceGroup, id: 43, name: 'Primary (Copy)', status: 'inactive' })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="group-duplicate"]').attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the API error and restores the action when duplication fails', async () => {
|
||||
duplicateGroup.mockRejectedValueOnce(new Error('duplicate failed'))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('duplicate failed')
|
||||
expect(wrapper.get('[data-testid="group-duplicate"]').attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not report a successful duplicate as failed when the refresh fails', async () => {
|
||||
listGroups
|
||||
.mockResolvedValueOnce({
|
||||
items: [sourceGroup],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="group-duplicate"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.groups.duplicateSuccess')
|
||||
expect(showError).toHaveBeenCalledWith('admin.groups.failedToLoad')
|
||||
expect(showError).not.toHaveBeenCalledWith('admin.groups.duplicateFailed')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user