diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index b86525d82..fcdfa6882 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -23,6 +23,7 @@ const usePromotionSourceStateMock = vi.fn() const submitPromotionMock = vi.fn() const submitRevocationMock = vi.fn() const directRevocationMock = vi.fn() +const deleteSkillMock = vi.fn() const useRevocationHistoryMock = vi.fn() const routeParams = { namespace: 'global', slug: 'demo-skill' } const searchMock = vi.hoisted(() => ({ value: { returnTo: '/dashboard/skills', version: undefined as string | undefined } })) @@ -212,7 +213,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useSkillReadme: (...args: unknown[]) => useSkillReadmeMock(...args), useSkillFile: (...args: unknown[]) => useSkillFileMock(...args), useArchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), - useDeleteSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteSkill: () => ({ mutateAsync: deleteSkillMock, isPending: false }), useDeleteSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), useRereleaseSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), useUnarchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), @@ -296,6 +297,7 @@ describe('SkillDetailPage', () => { usePromotionSourceStateMock.mockReturnValue({ data: undefined, isLoading: false, error: null }) searchMock.value = { returnTo: '/dashboard/skills', version: undefined } navigateMock.mockReset() + deleteSkillMock.mockReset() useSkillFilesMock.mockReset() useSkillReadmeMock.mockReset() useSkillFileMock.mockReset() @@ -334,6 +336,26 @@ describe('SkillDetailPage', () => { useSkillFileMock.mockReturnValue({ data: null, isLoading: false, error: null }) }) + it('consumes protected deletion failures at the actual button boundary and allows retry', async () => { + deleteSkillMock.mockRejectedValue(new Error('revoke global publication first')) + render() + fireEvent.click(screen.getByRole('button', { name: 'skillDetail.deleteSkill' })) + fireEvent.click(within(screen.getByRole('dialog', { name: 'skillDetail.deleteSkillConfirmTitle' })) + .getByRole('button', { name: 'skillDetail.deleteSkillContinue' })) + const dialog = await screen.findByRole('dialog', { name: 'skillDetail.deleteSkillInputTitle' }) + fireEvent.change(within(dialog).getByPlaceholderText('skillDetail.deleteSkillInputPlaceholder'), { target: { value: 'demo-skill' } }) + for (let attempt = 1; attempt <= 2; attempt++) { + fireEvent.click(within(dialog).getByRole('button', { name: 'skillDetail.deleteSkillFinal' })) + await waitFor(() => expect(deleteSkillMock).toHaveBeenCalledTimes(attempt)) + expect(within(dialog).getByDisplayValue('demo-skill')).toBeTruthy() + } + expect(deleteSkillMock).toHaveBeenLastCalledWith({ namespace: 'global', slug: 'demo-skill', ownerId: 'owner-1' }) + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.error).not.toHaveBeenCalled() // The real MutationCache owns feedback, tested separately. + expect(navigateMock).not.toHaveBeenCalled() + // An uncaught async onClick rejection also makes Vitest fail this real-page run. + }) + it('shows the avatar once beside the title, without an owner management section', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ avatarUrl: '/approved-avatar.png' }), isLoading: false, isFetching: false, error: null }) const view = render() diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 18481f8e3..caf8f2a41 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -611,7 +611,13 @@ export function SkillDetailPage() { if (!skill || !isDeleteSlugConfirmationValid(skill.slug, deleteSkillInput)) { return } - await deleteSkillMutation.mutateAsync({ namespace, slug, ownerId: skill.ownerId }) + try { + await deleteSkillMutation.mutateAsync({ namespace, slug, ownerId: skill.ownerId }) + } catch { + // MutationCache already reports the error and handles expired sessions. + // Consume the button handler rejection without running success side effects. + return + } setSkillDeleted(true) toast.success( t('skillDetail.deleteSkillSuccessTitle'),