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'),