mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
test(namespace): cover invitation mutation races
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
27687713d2
commit
7d36d42056
5 changed files with 188 additions and 18 deletions
|
|
@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;
|
|||
|
||||
前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。
|
||||
|
||||
空间卡片和成员管理页提供邀请入口,管理页提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
|
||||
空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
|
||||
|
||||
#### Scenario: 剪贴板写入失败
|
||||
- **WHEN** 创建成功但浏览器拒绝写剪贴板
|
||||
|
|
|
|||
|
|
@ -13,5 +13,5 @@
|
|||
## 3. 验证与交付
|
||||
|
||||
- [ ] 3.1 覆盖角色、现有账号准入、单次/过期/作废/替换、已有成员和禁用 Namespace。
|
||||
- [ ] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
|
||||
- [x] 3.2 覆盖并发兑换、直接添加与兑换、生命周期与兑换、账号合并锁顺序。
|
||||
- [ ] 3.3 运行后端、前端、API 类型、容器化预览和浏览器流程,并进行独立 Review、Draft PR 和验收报告。
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue