fix(web): refresh owner permissions after rejected updates

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-19 16:24:55 +08:00
parent 5639605029
commit 1826fa6768
2 changed files with 57 additions and 0 deletions

View file

@ -0,0 +1,51 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ detail: vi.fn(), publish: vi.fn(), navigate: vi.fn() }))
vi.mock('@/api/client', async () => ({
...await vi.importActual<typeof import('@/api/client')>('@/api/client'),
fetchJson: api.detail,
skillAvatarApi: { status: vi.fn().mockResolvedValue({ avatarUrl: '/active' }) },
}))
vi.mock('@/shared/hooks/use-skill-queries', async () => ({
...await vi.importActual<typeof import('@/shared/hooks/use-skill-queries')>('@/shared/hooks/use-skill-queries'),
previewSkillPackage: vi.fn().mockResolvedValue({ name: 'foo', displayName: 'Foo', version: '1.1' }),
usePublishSkill: () => ({ mutateAsync: api.publish, isPending: false }),
}))
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => api.navigate, useSearch: () => ({ namespace: 'team', updateSkill: 'foo' }) }))
vi.mock('react-i18next', async () => ({ ...await vi.importActual<typeof import('react-i18next')>('react-i18next'), useTranslation: () => ({ t: (key: string) => key }) }))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [{ slug: 'team', displayName: 'Team' }], isLoading: false }) }))
vi.mock('@/features/publish/upload-zone', () => ({ UploadZone: ({ onFileSelect }: { onFileSelect: (file: File) => void }) => <button onClick={() => onFileSelect(new File(['zip'], 'skill.zip'))}>choose package</button> }))
vi.mock('@/features/skill/skill-avatar-manager', () => ({ SkillAvatarManager: () => null }))
vi.mock('@/features/skill/skill-avatar-editor', () => ({ KEEP_AVATAR: { action: 'KEEP', ready: true }, SkillAvatarEditor: () => null }))
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import { PublishPage } from './publish'
import { ApiError } from '@/api/client'
import { toast } from '@/shared/lib/toast'
afterEach(() => { cleanup(); vi.clearAllMocks() })
describe('package rejection refreshes current update permissions', () => {
it.each([true, false])('uses the server ownerReadOnly=%s response after a rejected package', async ownerReadOnly => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
api.detail.mockResolvedValueOnce({ ownerReadOnly: false }).mockResolvedValue({ ownerReadOnly })
api.publish.mockRejectedValue(new ApiError('Localized package rejection', 400))
render(<QueryClientProvider client={client}><PublishPage /></QueryClientProvider>)
await waitFor(() => expect(api.detail).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getByText('choose package'))
const submit = screen.getByRole('button', { name: 'publish.confirm' })
await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false))
fireEvent.click(submit)
await waitFor(() => expect(toast.error).toHaveBeenCalled())
expect(api.detail).toHaveBeenCalledTimes(2)
expect(api.publish).toHaveBeenCalledTimes(1)
expect(submit.hasAttribute('disabled')).toBe(ownerReadOnly)
expect(Boolean(screen.queryByText('mySkills.ownerReadOnly'))).toBe(ownerReadOnly)
expect(toast.success).not.toHaveBeenCalled()
expect(api.navigate).not.toHaveBeenCalled()
cleanup()
client.clear()
})
})

View file

@ -27,6 +27,7 @@ import { Card } from '@/shared/ui/card'
import { SkillAvatarEditor, KEEP_AVATAR, type AvatarEdit } from '@/features/skill/skill-avatar-editor'
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
import { previewSkillPackage, usePublishSkill, useSkillDetail } from '@/shared/hooks/use-skill-queries'
import { getSkillDetailQueryKey } from '@/shared/hooks/query-keys'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
@ -167,6 +168,11 @@ export function PublishPage() {
navigate({ to: '/dashboard/skills' })
})
} catch (error) {
// A package rejection may also reflect changed membership. Re-read the
// server state instead of inferring permissions from its localized message.
if (isUpdate && !avatarOnly && error instanceof ApiError && error.status === 400) {
await queryClient.invalidateQueries({ queryKey: getSkillDetailQueryKey(namespaceSlug, updateSlug ?? '') })
}
// Avatar-only updates call the API directly rather than a mutation.
if (avatarOnly) await refreshSkillPermissionsAfterForbidden(queryClient, error)
if (error instanceof ApiError && error.status === 408) {