From 7d36d420565248a007d0aeb1fc3d79ef62ed020f Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:15:49 +0800 Subject: [PATCH] test(namespace): cover invitation mutation races Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../specs/namespace-invitations/spec.md | 2 +- .../add-namespace-invite-links/tasks.md | 2 +- .../NamespaceInvitationPostgresTest.java | 177 +++++++++++++++++- .../namespace/invitation-controls.tsx | 15 +- web/src/pages/dashboard/my-namespaces.tsx | 10 +- 5 files changed, 188 insertions(+), 18 deletions(-) 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 c793af05..5d370443 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 @@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码; 前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。 -空间卡片和成员管理页提供邀请入口,管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 +空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。 #### Scenario: 剪贴板写入失败 - **WHEN** 创建成功但浏览器拒绝写剪贴板 diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md index ebefc1b5..1e2f9f3d 100644 --- a/openspec/changes/add-namespace-invite-links/tasks.md +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -13,5 +13,5 @@ ## 3. 验证与交付 - [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。 -- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 +- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。 - [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java index a64c8cdc..b677e1bc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -1,17 +1,30 @@ package com.iflytek.skillhub.repository; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.auth.merge.AccountMergeRequest; +import com.iflytek.skillhub.auth.merge.AccountMergeRequestRepository; +import com.iflytek.skillhub.auth.merge.AccountMergeService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceInvitationService; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.infra.jpa.NamespaceInvitationJpaRepository; import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository; import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; import java.time.Clock; +import java.time.Instant; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -21,6 +34,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; @@ -40,7 +54,10 @@ import static org.assertj.core.api.Assertions.assertThat; @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") @Testcontainers -@Import({NamespaceInvitationService.class, AuditLogService.class, NamespaceInvitationPostgresTest.ClockConfig.class}) +@Import({NamespaceInvitationService.class, NamespaceMemberService.class, NamespaceService.class, + AccountMergeService.class, + NamespaceAccessPolicy.class, NamespaceGovernanceService.class, AuditLogService.class, + NamespaceInvitationPostgresTest.ClockConfig.class}) @Transactional(propagation = Propagation.NOT_SUPPORTED) class NamespaceInvitationPostgresTest { @Container @@ -63,11 +80,19 @@ class NamespaceInvitationPostgresTest { } @Autowired private NamespaceInvitationService invitations; + @Autowired private NamespaceMemberService memberService; + @Autowired private NamespaceGovernanceService governance; + @Autowired private NamespaceService namespaceService; + @Autowired private AccountMergeService accountMerge; + @Autowired private AccountMergeRequestRepository mergeRequests; @Autowired private NamespaceInvitationJpaRepository invitationRows; @Autowired private NamespaceJpaRepository namespaces; @Autowired private NamespaceMemberJpaRepository members; @Autowired private UserAccountJpaRepository users; @Autowired private PlatformTransactionManager transactionManager; + @MockBean private SkillRepository skills; + @MockBean private ReviewTaskRepository reviewTasks; + @MockBean private PromotionRequestRepository promotions; @Test void twoConcurrentAcceptsCreateOnlyOneMemberAndOneConsumption() throws Exception { @@ -136,6 +161,156 @@ class NamespaceInvitationPostgresTest { } } + @Test + void directAddAndAcceptProduceOneMembershipWithoutConsumingUnusedLink() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean acceptedAsExistingMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, guest).alreadyMember(); + }); + var direct = pool.submit(() -> { + start.await(); + try { + memberService.addMember(namespaceId, guest, NamespaceRole.MEMBER, owner); + return true; + } catch (DomainBadRequestException alreadyMember) { + assertThat(alreadyMember).hasMessageContaining("error.namespace.member.alreadyExists"); + return false; + } + }); + start.countDown(); + acceptedAsExistingMember = accept.get(20, TimeUnit.SECONDS); + boolean directAdded = direct.get(20, TimeUnit.SECONDS); + assertThat(acceptedAsExistingMember).isEqualTo(directAdded); + } + assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(guest))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(!acceptedAsExistingMember); + } + + @Test + void freezeAndArchiveSerializeWithAcceptance() throws Exception { + for (boolean archive : new boolean[] {false, true}) { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var lifecycle = pool.submit(() -> { + start.await(); + if (archive) governance.archiveNamespace(slug, owner, "test", null, null, null); + else governance.freezeNamespace(slug, owner, "test", null, null, null); + return true; + }); + start.countDown(); + int accepted = accept.get(20, TimeUnit.SECONDS); + assertThat(lifecycle.get(20, TimeUnit.SECONDS)).isTrue(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest).isPresent()) + .isEqualTo(accepted == 1); + assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedAt() != null) + .isEqualTo(accepted == 1); + } + } + } + + @Test + void deletingNamespaceAndAcceptingInvitationLeaveNoOrphan() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String guest = "inv-guest-" + suffix; + String slug = "inv-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String token = invitations.create(slug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> attempt(start, token, guest)); + var deletion = pool.submit(() -> { + start.await(); + namespaceService.deleteNamespace(namespaceId, owner); + return true; + }); + start.countDown(); + accept.get(20, TimeUnit.SECONDS); + assertThat(deletion.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(namespaces.findBySlug(slug)).isEmpty(); + assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest)).isEmpty(); + } + + @Test + void overlappingNamespaceAccountMergeAndAcceptKeepOnePrimaryMembership() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "inv-owner-" + suffix; + String primary = "inv-a-" + suffix; + String secondary = "inv-z-" + suffix; + String inviteSlug = "inv-b-" + suffix; + Long[] ids = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(primary, primary, null, null)); + users.saveAndFlush(new UserAccount(secondary, secondary, null, null)); + Namespace shared = namespaces.saveAndFlush(new Namespace("inv-a-" + suffix, "Shared", owner)); + Namespace invited = namespaces.saveAndFlush(new Namespace(inviteSlug, "Invited", owner)); + for (Namespace ns : new Namespace[] {shared, invited}) { + members.saveAndFlush(new NamespaceMember(ns.getId(), owner, NamespaceRole.OWNER)); + members.saveAndFlush(new NamespaceMember(ns.getId(), secondary, NamespaceRole.MEMBER)); + } + members.saveAndFlush(new NamespaceMember(shared.getId(), primary, NamespaceRole.MEMBER)); + AccountMergeRequest request = new AccountMergeRequest(primary, secondary, null, + Instant.now().plusSeconds(3600)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + return new Long[] {shared.getId(), invited.getId(), mergeRequests.saveAndFlush(request).getId()}; + }); + String token = invitations.create(inviteSlug, owner).token(); + CountDownLatch start = new CountDownLatch(1); + boolean alreadyMember; + try (var pool = Executors.newFixedThreadPool(2)) { + var accept = pool.submit(() -> { + start.await(); + return invitations.accept(token, primary).alreadyMember(); + }); + var merge = pool.submit(() -> { + start.await(); + accountMerge.confirm(primary, ids[2]); + return true; + }); + start.countDown(); + alreadyMember = accept.get(20, TimeUnit.SECONDS); + assertThat(merge.get(20, TimeUnit.SECONDS)).isTrue(); + } + assertThat(users.findAllById(java.util.List.of(secondary)).iterator().next().getStatus()).isEqualTo(UserStatus.MERGED); + assertThat(members.findByNamespaceIdAndUserId(ids[0], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], secondary)).isEmpty(); + assertThat(members.findByNamespaceIdAndUserId(ids[1], primary)).isPresent(); + assertThat(members.findByNamespaceId(ids[1], org.springframework.data.domain.Pageable.unpaged()) + .getContent().stream().filter(member -> member.getUserId().equals(primary))).hasSize(1); + assertThat(invitationRows.findByNamespaceId(ids[1]).orElseThrow().getConsumedAt() != null) + .isEqualTo(!alreadyMember); + } + + private Long createTeam(String slug, String owner, String guest) { + return new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(guest, guest, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace(slug, "Invitation race", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + return namespace.getId(); + }); + } + private int attempt(CountDownLatch start, String token, String userId) throws InterruptedException { start.await(); try { diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index 058fa75a..fc014371 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -10,12 +10,12 @@ import { toast } from '@/shared/lib/toast' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' -export function InvitationControls({ slug }: { slug: string }) { +export function InvitationControls({ slug, compact = false }: { slug: string; compact?: boolean }) { const { t } = useTranslation() const queryClient = useQueryClient() const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null) const [link, setLink] = useState(null) - const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug) }) + const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug), enabled: !compact }) const create = useMutation({ mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace), onSuccess: async ({ token, replaced }) => { @@ -38,14 +38,15 @@ export function InvitationControls({ slug }: { slug: string }) { toast.success(t('invitation.revoked')) }, }) - const handleCreate = () => { - if (status.data?.active) setConfirm('replace') - else create.mutate(false) + const handleCreate = async () => { + const current = compact ? (await status.refetch()).data : status.data + if (current?.active) setConfirm('replace') + else if (current) create.mutate(false) } return ( -
+
event.stopPropagation() : undefined}>
- + {status.data?.active ? : null}
{link ? ( diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index afa08d22..48f60f7d 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -10,6 +10,7 @@ import { Pagination } from '@/shared/components/pagination' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog' +import { InvitationControls } from '@/features/namespace/invitation-controls' import { useArchiveNamespace, useDeleteNamespace, @@ -178,11 +179,6 @@ export function MyNamespacesPage() { navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/members` }) } - const handleInviteClick = (slug: string, e: React.MouseEvent) => { - e.stopPropagation() - navigate({ to: '/dashboard/namespaces/$slug/members', params: { slug }, hash: 'invite' }) - } - const handleReviewsClick = (slug: string, e: React.MouseEvent) => { e.stopPropagation() navigate({ to: `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` }) @@ -313,9 +309,7 @@ export function MyNamespacesPage() { )} {namespace.type === 'TEAM' && namespace.status === 'ACTIVE' && (namespace.currentUserRole === 'OWNER' || namespace.currentUserRole === 'ADMIN') && ( - + )}