You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

424 lines
18 KiB

package com.mydreamjob.onboarding;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:onboarding_concurrency;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnboardingConcurrencyIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(972_000_000L);
private static final AtomicLong ITEM_SEQ = new AtomicLong(1L);
@Autowired private OnboardingChecklistService checklistService;
@Autowired private CompanyKnowledgeService knowledgeService;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private OnboardingChecklistItemRepository checklistItemRepository;
@Autowired private StudentOnboardingProgressRepository progressRepository;
@Autowired private CompanyKnowledgeRepository knowledgeRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearData() {
progressRepository.deleteAll();
knowledgeRepository.deleteAll();
checklistItemRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void identicalIdempotencyKeyConcurrentCreatesReplaySameRow() throws Exception {
CompanyOwner owner = createCompanyOwner("幂等企业");
CompanyChecklistItemCreateRequest request = createRequest("重复请求", "same-key");
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(owner.userId(), request),
() -> checklistService.createCompanyChecklistItem(owner.userId(), request));
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(attempts).extracting(attempt -> attempt.value().getItemId())
.containsOnly(attempts.get(0).value().getItemId());
assertThat(companyRows(owner.companyId()).stream()
.filter(item -> "same-key".equals(item.getIdempotencyKey())))
.hasSize(1);
}
@Test
void idempotencyKeyRaceWithDifferentBodiesReturns9704() throws Exception {
CompanyOwner owner = createCompanyOwner("幂等冲突企业");
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("请求A", "conflict-key")),
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("请求B", "conflict-key")));
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1);
assertThat(attempts).filteredOn(Attempt::failedWithIdempotencyConflict).hasSize(1);
assertThat(companyRows(owner.companyId()).stream()
.filter(item -> "conflict-key".equals(item.getIdempotencyKey())))
.hasSize(1);
}
@Test
void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception {
CompanyOwner owner = createCompanyOwner("上限企业");
seedCompanyItems(owner.companyId(), 49);
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("边界A", "limit-a")),
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("边界B", "limit-b")));
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1);
assertThat(attempts).filteredOn(Attempt::failedWithItemLimit).hasSize(1);
assertThat(companyRows(owner.companyId()))
.filteredOn(item -> Boolean.TRUE.equals(item.getActive()))
.hasSize(50);
}
@Test
void inactiveItemsDoNotConsumeLimitAndExistingKeyStillReplays() throws Exception {
CompanyOwner owner = createCompanyOwner("停用项企业");
seedCompanyItems(owner.companyId(), 49);
CompanyChecklistItemCreateRequest fiftieth = createRequest("第50条", "replay-key");
CompanyChecklistItemView created = checklistService.createCompanyChecklistItem(
owner.userId(), fiftieth);
seedInactiveCompanyItems(owner.companyId(), 3);
CompanyChecklistItemCreateRequest newRequest = createRequest("第51条", "new-over-limit");
assertThat(failedWith(() -> checklistService.createCompanyChecklistItem(
owner.userId(), newRequest))).isEqualTo(
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode());
CompanyChecklistItemView replay = checklistService.createCompanyChecklistItem(
owner.userId(), fiftieth);
assertThat(replay.getItemId()).isEqualTo(created.getItemId());
assertThat(companyRows(owner.companyId()).stream()
.filter(item -> Boolean.TRUE.equals(item.getActive())))
.hasSize(50);
}
@Test
void sameIdempotencyKeyIsIsolatedByCompany() throws Exception {
CompanyOwner first = createCompanyOwner("企业甲");
CompanyOwner second = createCompanyOwner("企业乙");
CompanyChecklistItemCreateRequest request = createRequest("独立企业请求", "shared-key");
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(first.userId(), request),
() -> checklistService.createCompanyChecklistItem(second.userId(), request));
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(companyRows(first.companyId())).hasSize(1);
assertThat(companyRows(second.companyId())).hasSize(1);
}
@Test
void firstProgressCreateWithNonZeroVersionReturns9705() {
CompanyOwner owner = createCompanyOwner("进度版本企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem item = createStandardItem("进度版本项");
ProgressUpdateRequest request = progressRequest(true, 1L);
assertThat(failedWith(() -> checklistService.updateCompletion(
student, owner.companyId(), item.getId(), request)))
.isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode());
assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId()))
.isEmpty();
}
@Test
void concurrentFirstProgressCreatesProduceSingleRow() throws Exception {
CompanyOwner owner = createCompanyOwner("首次进度企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem item = createStandardItem("首次进度项");
List<Callable<ProgressUpdateResponse>> calls = new ArrayList<>();
for (int i = 0; i < 4; i++) {
calls.add(() -> checklistService.updateCompletion(
student, owner.companyId(), item.getId(), progressRequest(true, 0L)));
}
List<Attempt<ProgressUpdateResponse>> attempts = runConcurrently(calls);
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId()))
.isPresent();
assertThat(progressRepository.findAll().stream()
.filter(row -> row.getStudentUserId().equals(student)
&& row.getItemId().equals(item.getId())))
.hasSize(1);
}
@Test
void simultaneousFirstKnowledgeUpsertsAreDeterministic() throws Exception {
CompanyOwner owner = createCompanyOwner("知识并发企业");
CompanyKnowledgeUpdateRequest request = knowledgeRequest("共同文化", null, null, null);
List<Attempt<CompanyKnowledgeView>> attempts = runConcurrently(
() -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request),
() -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request));
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(knowledgeRepository.findByCompanyId(owner.companyId())).isPresent();
assertThat(knowledgeRepository.findAll().stream()
.filter(row -> row.getCompanyId().equals(owner.companyId())))
.hasSize(1);
}
@Test
void identicalKnowledgeCreateReplayWithoutVersionIsIdempotent() {
CompanyOwner owner = createCompanyOwner("知识重放企业");
CompanyKnowledgeUpdateRequest request = knowledgeRequest("重放文化", "制度", null, null);
knowledgeService.upsertCompanyKnowledge(owner.userId(), request);
CompanyKnowledgeView replay = knowledgeService.upsertCompanyKnowledge(owner.userId(), request);
assertThat(replay.getConfigured()).isTrue();
assertThat(replay.getVersion()).isZero();
assertThat(knowledgeRepository.findAll().stream()
.filter(row -> row.getCompanyId().equals(owner.companyId())))
.hasSize(1);
}
@Test
void concurrentKnowledgeUpdatesWithSameVersionReturn9706() throws Exception {
CompanyOwner owner = createCompanyOwner("知识版本企业");
knowledgeRepository.save(CompanyKnowledge.builder()
.companyId(owner.companyId())
.culture("初始文化")
.build());
List<Attempt<CompanyKnowledgeView>> attempts = runConcurrently(
() -> knowledgeService.upsertCompanyKnowledge(
owner.userId(), knowledgeRequest("更新A", null, null, 0L)),
() -> knowledgeService.upsertCompanyKnowledge(
owner.userId(), knowledgeRequest("更新B", null, null, 0L)));
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1);
assertThat(attempts).filteredOn(Attempt::failedWithKnowledgeConflict).hasSize(1);
assertThat(knowledgeRepository.findByCompanyId(owner.companyId()).orElseThrow().getVersion())
.isEqualTo(1L);
}
private int failedWith(ThrowingSupplier<?> supplier) {
try {
supplier.get();
return -1;
} catch (BusinessException ex) {
return ex.getErrorCodeInt();
}
}
private CompanyChecklistItemCreateRequest createRequest(String title, String idempotencyKey) {
CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest();
request.setTitle(title);
request.setDescription(title + "说明");
request.setCategory("ADMIN");
request.setSortOrder(200);
request.setRequired(false);
request.setIdempotencyKey(idempotencyKey);
return request;
}
private ProgressUpdateRequest progressRequest(boolean completed, Long version) {
ProgressUpdateRequest request = new ProgressUpdateRequest();
request.setCompleted(completed);
request.setVersion(version);
return request;
}
private CompanyKnowledgeUpdateRequest knowledgeRequest(
String culture, String workPolicies, String onboardingGuide, Long version) {
CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest();
request.setCulture(culture);
request.setWorkPolicies(workPolicies);
request.setOnboardingGuide(onboardingGuide);
request.setVersion(version);
return request;
}
private List<OnboardingChecklistItem> seedCompanyItems(Long companyId, int count) {
List<OnboardingChecklistItem> items = new ArrayList<>();
for (int i = 0; i < count; i++) {
items.add(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyId)
.companyId(companyId)
.itemKey("seed-" + companyId + "-" + ITEM_SEQ.getAndIncrement())
.title("种子项-" + i)
.description("种子说明")
.category("OTHER")
.sortOrder(1000 + i)
.required(false)
.active(true)
.idempotencyKey("seed-key-" + companyId + "-" + i)
.requestHash("a".repeat(64))
.build());
}
return checklistItemRepository.saveAllAndFlush(items);
}
private void seedInactiveCompanyItems(Long companyId, int count) {
List<OnboardingChecklistItem> items = new ArrayList<>();
for (int i = 0; i < count; i++) {
items.add(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyId)
.companyId(companyId)
.itemKey("inactive-" + companyId + "-" + ITEM_SEQ.getAndIncrement())
.title("停用项-" + i)
.category("OTHER")
.sortOrder(2000 + i)
.required(false)
.active(false)
.build());
}
checklistItemRepository.saveAllAndFlush(items);
}
private OnboardingChecklistItem createStandardItem(String title) {
return checklistItemRepository.saveAndFlush(OnboardingChecklistItem.builder()
.scopeType("STANDARD")
.scopeId(0L)
.companyId(null)
.itemKey("standard-" + ITEM_SEQ.getAndIncrement())
.title(title)
.description("通用说明")
.category("DOCUMENT")
.sortOrder(10)
.required(true)
.active(true)
.build());
}
private List<OnboardingChecklistItem> companyRows(Long companyId) {
return checklistItemRepository.findAll().stream()
.filter(item -> companyId.equals(item.getCompanyId()))
.toList();
}
private CompanyOwner createCompanyOwner(String name) {
Long userId = createUser(User.Role.COMPANY);
Long companyId = companyRepository.save(Company.builder()
.ownerUserId(userId)
.name(name)
.verified(true)
.build()).getId();
return new CompanyOwner(userId, companyId);
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone(String.valueOf(PHONE_SEQ.getAndIncrement()))
.nickname(role.name())
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build()).getId();
}
@SafeVarargs
private final <T> List<Attempt<T>> runConcurrently(Callable<T>... actions) throws Exception {
return runConcurrently(List.of(actions));
}
private <T> List<Attempt<T>> runConcurrently(List<Callable<T>> actions) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(actions.size());
CountDownLatch ready = new CountDownLatch(actions.size());
CountDownLatch start = new CountDownLatch(1);
List<Future<Attempt<T>>> futures = new ArrayList<>();
try {
for (Callable<T> action : actions) {
futures.add(pool.submit(() -> {
ready.countDown();
if (!start.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("concurrent test start timed out");
}
try {
return new Attempt<>(action.call(), null);
} catch (Throwable ex) {
return new Attempt<>(null, ex);
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<Attempt<T>> results = new ArrayList<>();
for (Future<Attempt<T>> future : futures) {
results.add(future.get(20, TimeUnit.SECONDS));
}
return results;
} finally {
pool.shutdownNow();
}
}
private record CompanyOwner(Long userId, Long companyId) {
}
private record Attempt<T>(T value, Throwable error) {
boolean failedWithIdempotencyConflict() {
return error instanceof BusinessException ex
&& ex.getErrorCodeInt() == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode();
}
boolean failedWithItemLimit() {
return error instanceof BusinessException ex
&& ex.getErrorCodeInt() == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode();
}
boolean failedWithKnowledgeConflict() {
return error instanceof BusinessException ex
&& ex.getErrorCodeInt()
== ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode();
}
}
@FunctionalInterface
private interface ThrowingSupplier<T> {
T get();
}
}