fix(namespace): refresh invitation state before creating link

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-13 19:16:01 +08:00
parent 3de13c033f
commit efbb50f4aa
3 changed files with 47 additions and 3 deletions

View file

@ -116,3 +116,8 @@ API SHALL 使用 Issue #911 的路径与响应合同。
- **THEN** 按原方式安全捕获并清除片段,保持兼容
- **WHEN** 新的具名片段格式无效
- **THEN** 清除先前暂存凭证,不能误用上一条邀请;无片段的登录回跳仍恢复合法暂存邀请
#### Scenario: 他人消费邀请后生成新链接
- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态
- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认
- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误

View file

@ -34,6 +34,10 @@ describe('InvitationControls', () => {
api.createInvitation.mockRejectedValue(new Error('unavailable'))
api.revokeInvitation.mockRejectedValue(new Error('unavailable'))
const dialog = await openConfirmation(action)
if (action === 'replace') {
expect(api.invitationStatus).toHaveBeenCalledTimes(2)
expect(api.createInvitation).not.toHaveBeenCalled()
}
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' }))
@ -41,4 +45,37 @@ describe('InvitationControls', () => {
expect(screen.getByRole('alert').textContent).toBe('invitation.error')
expect(action === 'replace' ? api.createInvitation : api.revokeInvitation).toHaveBeenCalledTimes(1)
})
it.each(['consumed', 'expired'])('creates without a replacement prompt when the cached link was %s', async () => {
api.invitationStatus.mockResolvedValueOnce({ active: true }).mockResolvedValue({ active: false })
api.createInvitation.mockImplementation(() => new Promise(() => {}))
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
const createButton = screen.getByRole('button', { name: 'invitation.create' })
await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false))
expect(screen.getByRole('button', { name: 'invitation.revoke' })).toBeTruthy()
fireEvent.click(createButton)
await waitFor(() => expect(api.createInvitation).toHaveBeenCalledWith('team', false))
expect(api.invitationStatus).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('does not create or confirm replacement when the fresh status fails despite cached active data', async () => {
api.invitationStatus.mockResolvedValueOnce({ active: true }).mockRejectedValue(new Error('unavailable'))
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
const createButton = screen.getByRole('button', { name: 'invitation.create' })
await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false))
fireEvent.click(createButton)
await screen.findByRole('alert')
expect(api.invitationStatus).toHaveBeenCalledTimes(2)
expect(api.createInvitation).not.toHaveBeenCalled()
expect(api.revokeInvitation).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
})

View file

@ -41,9 +41,11 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
},
})
const handleCreate = async () => {
const current = compact ? (await status.refetch()).data : status.data
if (current?.active) setConfirm('replace')
else if (current) create.mutate(false)
const result = await status.refetch()
if (result.isError || !result.data) return
const current = result.data
if (current.active) setConfirm('replace')
else create.mutate(false)
}
return (
<div id={compact ? undefined : 'invite'} className="space-y-2 scroll-mt-24" onClick={compact ? (event) => event.stopPropagation() : undefined}>