From d9c658a459377ca0cf495207179b76b85cc890d9 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:54:18 +0800 Subject: [PATCH] fix(avatar): refresh owner and review caches after mutations Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../design.md | 6 +- .../publish/publish-avatar-cache.test.tsx | 39 +++++++++++ .../skill/skill-avatar-cache.test.tsx | 66 +++++++++++++++++++ .../features/skill/skill-avatar-manager.tsx | 2 +- .../features/skill/skill-avatar-review.tsx | 9 ++- web/src/shared/hooks/use-skill-queries.ts | 8 ++- 6 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 web/src/features/publish/publish-avatar-cache.test.tsx create mode 100644 web/src/features/skill/skill-avatar-cache.test.tsx diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md index 7fe8a300..2079000b 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/design.md +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -22,7 +22,7 @@ - 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。 -## 已确认的发布交互调整(2026-10-13,待实施) +## 已确认的发布交互调整(2026-10-13,已实施,浏览器验收中) 本节补充原实现,需求以 `specs/skill-avatars/spec.md` 的 REQ-AV-09/10/11 为准。 @@ -35,4 +35,6 @@ - 未关联临时图片始终私有,按有界任务定期清理;关联与清理并发须保证不会删除已绑定图片。临时引用使用独立 `skill_avatar_upload` 表,24 小时过期;上传接口为 `/api/web/skills/avatar-uploads`,提交技能携带 `avatarAction`(默认 KEEP,可选 REPLACE/REMOVE)和替换时的 `avatarUploadId`。单独维护使用现有头像路径的 JSON 提交;更新携带 `expectedSlug` 校验技能包目标。清理和引用消费均锁定临时上传记录,发布与独立修改均采用技能→上传记录的锁顺序。不引入业务状态 SQL CHECK 约束。 - 唯一机器供应商的部署选择、后台两个审核开关、PRIVATE 直接使用规则保持不变。 -原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;实施后须补充对应测试和浏览器验收。真实供应商调用仍未验证。 +原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;本轮新增/更新及独立维护流程按对应测试和浏览器验收重新验证。真实供应商调用仍未验证。 + +同浏览器的发布、独立维护和审核决定成功后,须等待刷新技能展示、对应所有者头像状态及头像审核队列缓存;不以等待缓存过期替代状态更新。回归以真实 QueryClient 下旧状态变为提交后的状态为 Oracle,并验证无关技能的所有者缓存不被失效。 diff --git a/web/src/features/publish/publish-avatar-cache.test.tsx b/web/src/features/publish/publish-avatar-cache.test.tsx new file mode 100644 index 00000000..77e23d23 --- /dev/null +++ b/web/src/features/publish/publish-avatar-cache.test.tsx @@ -0,0 +1,39 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' + +const transport = vi.hoisted(() => ({ fetchJson: vi.fn() })) +vi.mock('@/api/client', async () => ({ ...await vi.importActual('@/api/client'), fetchJson: transport.fetchJson, getCsrfHeaders: () => ({}) })) +import { usePublishSkill } from './use-publish-skill' + +describe('publishing refreshes owner avatar state', () => { + afterEach(() => { cleanup(); transport.fetchJson.mockReset() }) + + it('refreshes the active owner avatar query before the successful update returns', async () => { + const key = ['skill-avatar', 'team', 'foo'] + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(key, { avatarUrl: '/old-avatar' }) + client.setQueryData(['skill-avatar', 'other-team', 'bar'], { avatarUrl: '/unrelated' }) + client.setQueryData(['skill-avatar-review'], []) + const loadQueue = vi.fn().mockResolvedValue([{ candidateId: 42 }]) + const loadAvatar = vi.fn().mockResolvedValue({ avatarUrl: null }) + transport.fetchJson.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1.0', status: 'PENDING_REVIEW' }) + const wrapper = ({ children }: { children: ReactNode }) => {children} + const hook = renderHook(() => { + useQuery({ queryKey: key, queryFn: loadAvatar, staleTime: Infinity }) + useQuery({ queryKey: ['skill-avatar-review'], queryFn: loadQueue, staleTime: Infinity }) + return usePublishSkill() + }, { wrapper }) + expect(loadAvatar).not.toHaveBeenCalled() + + await act(() => hook.result.current.mutateAsync({ namespace: 'team', file: new File(['zip'], 'skill.zip'), visibility: 'PUBLIC', avatarAction: 'REMOVE' })) + + await waitFor(() => expect(client.getQueryData(key)).toEqual({ avatarUrl: null })) + expect(loadAvatar).toHaveBeenCalledOnce() + expect(client.getQueryData(['skill-avatar-review'])).toEqual([{ candidateId: 42 }]) + expect(client.getQueryData(['skill-avatar', 'other-team', 'bar'])).toEqual({ avatarUrl: '/unrelated' }) + expect(client.getQueryState(['skill-avatar', 'other-team', 'bar'])?.isInvalidated).toBe(false) + }) +}) diff --git a/web/src/features/skill/skill-avatar-cache.test.tsx b/web/src/features/skill/skill-avatar-cache.test.tsx new file mode 100644 index 00000000..c7f7f5ef --- /dev/null +++ b/web/src/features/skill/skill-avatar-cache.test.tsx @@ -0,0 +1,66 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider, useQuery } 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(() => ({ status: vi.fn(), submit: vi.fn(), queue: vi.fn(), decide: vi.fn() })) +vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('./skill-avatar-editor', () => ({ + KEEP_AVATAR: { action: 'KEEP', ready: true }, + SkillAvatarEditor: ({ onChange }: { onChange: (value: { action: 'REPLACE'; ready: boolean; uploadId: string }) => void }) => , +})) +import { SkillAvatarManager } from './skill-avatar-manager' +import { SkillAvatarReviewQueue } from './skill-avatar-review' + +const ownerKey = ['skill-avatar', 'team', 'foo'] +const queueKey = ['skill-avatar-review'] +const pending = { namespace: 'team', slug: 'foo', avatarUrl: null, candidateId: 42, candidateStatus: 'PENDING', machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: null } +const active = { ...pending, avatarUrl: '/active', candidateStatus: 'ACTIVE', reviewStatus: 'APPROVED' } + +describe('avatar cache across owner and reviewer flows', () => { + afterEach(() => { cleanup(); Object.values(api).forEach(fn => fn.mockReset()) }) + + it('refreshes a previously empty fresh review queue when the owner submits a staged image', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + client.setQueryData(ownerKey, { ...active, candidateId: 20 }) + client.setQueryData(queueKey, []) + api.status.mockResolvedValue(pending) + api.submit.mockResolvedValue(pending) + api.queue.mockResolvedValue([pending]) + render() + expect(screen.getByText('skillAvatar.emptyQueue')).toBeTruthy() + expect(api.queue).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('Use staged image')) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' })) + + expect(await screen.findByText('@team/foo')).toBeTruthy() + expect(client.getQueryData(ownerKey)).toEqual(pending) + expect(client.getQueryData(queueKey)).toEqual([pending]) + }) + + it('refreshes the owner status and skill display after the owner approves their own candidate', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + const skillKey = ['skills', 'team', 'foo', 'en'] + client.setQueryData(ownerKey, pending) + client.setQueryData(queueKey, [pending]) + client.setQueryData(skillKey, { avatarUrl: null }) + api.status.mockResolvedValue(active) + api.queue.mockResolvedValue([]) + api.decide.mockResolvedValue(active) + function OwnerView() { + const status = useQuery({ queryKey: ownerKey, queryFn: () => api.status('team', 'foo') }) + const skill = useQuery({ queryKey: skillKey, queryFn: async () => ({ avatarUrl: '/active' }) }) + return

{status.data?.candidateStatus}/{skill.data?.avatarUrl ?? 'fallback'}

+ } + render() + expect(screen.getByTestId('owner-view').textContent).toBe('PENDING/fallback') + fireEvent.change(screen.getByLabelText('skillAvatar.reason'), { target: { value: 'Approved local image' } }) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.approve' })) + + await waitFor(() => expect(screen.getByTestId('owner-view').textContent).toBe('ACTIVE//active')) + expect(screen.queryByText('@team/foo')).toBeNull() + expect(client.getQueryData(ownerKey)).toEqual(active) + expect(client.getQueryData(queueKey)).toEqual([]) + }) +}) diff --git a/web/src/features/skill/skill-avatar-manager.tsx b/web/src/features/skill/skill-avatar-manager.tsx index fe63b349..2cfa428f 100644 --- a/web/src/features/skill/skill-avatar-manager.tsx +++ b/web/src/features/skill/skill-avatar-manager.tsx @@ -27,7 +27,7 @@ export function SkillAvatarManager({ namespace, slug, name = slug, readOnly = fa setEdit(KEEP_AVATAR) setEditorVersion(value => value + 1) } - await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })]) + await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] }), queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] })]) }, onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), }) diff --git a/web/src/features/skill/skill-avatar-review.tsx b/web/src/features/skill/skill-avatar-review.tsx index 27ffc401..407960d2 100644 --- a/web/src/features/skill/skill-avatar-review.tsx +++ b/web/src/features/skill/skill-avatar-review.tsx @@ -15,7 +15,14 @@ export function SkillAvatarReviewQueue({ namespace }: { namespace?: string }) { const decide = useMutation({ mutationFn: (input: { namespace: string; slug: string; candidateId: number; approved: boolean; reason: string }) => skillAvatarApi.decide(input.namespace, input.slug, input.candidateId, input.approved, input.reason), - onSuccess: async () => { setError(''); await queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }) }, + onSuccess: async (_data, input) => { + setError('') + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }), + queryClient.invalidateQueries({ queryKey: ['skill-avatar', input.namespace, input.slug] }), + queryClient.invalidateQueries({ queryKey: ['skills'] }), + ]) + }, onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')), }) const items = queue.data?.filter((item) => !namespace || item.namespace === namespace) ?? [] diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 90ffb494..84328e8f 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -145,8 +145,12 @@ export function usePublishSkill() { meta: { skipGlobalErrorHandler: true, }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['skills'] }) + onSuccess: async (result) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['skills'] }), + queryClient.invalidateQueries({ queryKey: ['skill-avatar', result.namespace, result.slug] }), + queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }), + ]) }, }) }