diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index c384d4d6..da305791 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -116,3 +116,8 @@ API SHALL 使用 Issue #911 的路径与响应合同。 - **THEN** 按原方式安全捕获并清除片段,保持兼容 - **WHEN** 新的具名片段格式无效 - **THEN** 清除先前暂存凭证,不能误用上一条邀请;无片段的登录回跳仍恢复合法暂存邀请 + +#### Scenario: 他人消费邀请后生成新链接 +- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态 +- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认 +- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误 diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx index 9397b03e..df80128e 100644 --- a/web/src/features/namespace/invitation-controls.test.tsx +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -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() + 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() + 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() + }) + }) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index c407aa3d..043e59c5 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -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 (
event.stopPropagation() : undefined}>