test(namespace): cover invitation mutation races

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 12:15:49 +08:00
parent 27687713d2
commit 7d36d42056
5 changed files with 188 additions and 18 deletions

View file

@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;
前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。
空间卡片和成员管理页提供邀请入口,管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
#### Scenario: 剪贴板写入失败
- **WHEN** 创建成功但浏览器拒绝写剪贴板

View file

@ -13,5 +13,5 @@
## 3. 验证与交付
- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。

View file

@ -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 {

View file

@ -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<string | null>(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 (
<div id="invite" className="space-y-2 scroll-mt-24">
<div id={compact ? undefined : 'invite'} className="space-y-2 scroll-mt-24" onClick={compact ? (event) => event.stopPropagation() : undefined}>
<div className="flex gap-2">
<Button variant="outline" disabled={status.isPending || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
<Button variant="outline" size={compact ? 'sm' : 'default'} disabled={(!compact && status.isPending) || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
{status.data?.active ? <Button variant="outline" disabled={revoke.isPending} onClick={() => setConfirm('revoke')}>{t('invitation.revoke')}</Button> : null}
</div>
{link ? (

View file

@ -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') && (
<Button variant="outline" size="sm" onClick={(e) => handleInviteClick(namespace.slug, e)}>
{t('invitation.create')}
</Button>
<InvitationControls slug={namespace.slug} compact />
)}
<Button
variant="outline"