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.
 
 
 

270 lines
12 KiB

package com.mydreamjob.resume;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.resume.storage.ResumeExportStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 简历版本持久化与并发集成测试:H2 + Flyway V14/V15 真实表结构。
* 验证:唯一默认约束、version_no 唯一、导出任务 FK ON DELETE SET NULL、
* 并发设默认/并发创建、导出任务使用创建时的源文件快照。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class ResumeVersionPersistenceIntegrationTest {
@Autowired private ResumeVersionRepository versionRepository;
@Autowired private ResumeExportTaskRepository taskRepository;
@Autowired private ResumeVersionService resumeVersionService;
@Autowired private ResumeExportService resumeExportService;
@Autowired private ResumeExportStorageService resumeExportStorageService;
@Autowired private UserRepository userRepository;
@Autowired private PlatformTransactionManager transactionManager;
@Value("${app.resume.storage-dir:./target/resumes}")
private String storageDir;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long userId;
private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ =
new java.util.concurrent.atomic.AtomicLong(200);
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
taskRepository.deleteAll();
versionRepository.deleteAll();
User student = userRepository.save(User.builder()
.phone(uniquePhone()).nickname("简历候选人")
.role(User.Role.STUDENT).active(true).verified(true).build());
userId = student.getId();
}
private String uniquePhone() {
return "188" + String.format("%08d", PHONE_SEQ.incrementAndGet());
}
private ResumeVersion newVersion(int versionNo) {
return ResumeVersion.builder()
.userId(userId).name("版本" + versionNo).versionNo(versionNo)
.fileStoragePath("100_" + versionNo + ".pdf").fileName("r.pdf")
.contentType("application/pdf").fileSize(7L)
.defaultKey(null).status(ResumeVersion.STATUS_ACTIVE)
.build();
}
@Test
void uniqueDefault_databaseConstraint_enforced() {
ResumeVersion v1 = newVersion(1);
v1.setDefaultKey(ResumeVersion.DEFAULT_KEY);
versionRepository.saveAndFlush(v1);
ResumeVersion v2 = newVersion(2);
v2.setDefaultKey(ResumeVersion.DEFAULT_KEY);
assertThrows(DataIntegrityViolationException.class,
() -> versionRepository.saveAndFlush(v2));
}
@Test
void multipleNonDefault_areAllowed() {
versionRepository.saveAndFlush(newVersion(1));
versionRepository.saveAndFlush(newVersion(2));
versionRepository.saveAndFlush(newVersion(3));
assertThat(versionRepository.count()).isEqualTo(3);
}
@Test
void versionNo_uniquePerUser() {
versionRepository.saveAndFlush(newVersion(1));
assertThrows(DataIntegrityViolationException.class,
() -> versionRepository.saveAndFlush(newVersion(1)));
}
@Test
void exportTask_fkSetNull_onVersionDelete() {
ResumeVersion version = versionRepository.saveAndFlush(newVersion(1));
ResumeExportTask task = taskRepository.saveAndFlush(ResumeExportTask.builder()
.userId(userId).resumeVersionId(version.getId())
.resumeVersionName(version.getName()).status(ResumeExportTask.STATUS_SUCCESS).build());
versionRepository.delete(version);
ResumeExportTask reloaded = taskRepository.findById(task.getId()).orElseThrow();
assertNull(reloaded.getResumeVersionId());
assertThat(reloaded.getStatus()).isEqualTo(ResumeExportTask.STATUS_SUCCESS);
}
@Test
void setDefault_concurrently_shouldLeaveExactlyOneDefault() throws Exception {
ResumeVersion v1 = versionRepository.saveAndFlush(newVersion(1));
ResumeVersion v2 = versionRepository.saveAndFlush(newVersion(2));
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
pool.submit(() -> runWithLatch(start, () -> resumeVersionService.setDefault(userId, v1.getId()), failure));
pool.submit(() -> runWithLatch(start, () -> resumeVersionService.setDefault(userId, v2.getId()), failure));
start.countDown();
pool.shutdown();
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
// 无论哪个线程获胜/冲突,最终数据库必须恰好一个默认
long defaults = versionRepository.findAll().stream()
.filter(ResumeVersion::isDefault).count();
assertThat(defaults).isEqualTo(1);
}
@Test
void createVersion_concurrently_shouldProduceDistinctVersionNos() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("data"));
pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "a", file), failure));
pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "b", file), failure));
start.countDown();
pool.shutdown();
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
if (failure.get() != null) {
fail("concurrent create failed: " + failure.get());
}
var versions = versionRepository.findByUserIdAndStatusOrderByVersionNoDesc(userId, ResumeVersion.STATUS_ACTIVE);
assertThat(versions).hasSize(2);
assertThat(versions.get(0).getVersionNo()).isNotEqualTo(versions.get(1).getVersionNo());
}
/**
* 端到端:创建导出任务(快照文件 A)后修改版本文件为 B,
* 导出结果仍必须是创建任务时的文件 A。
*/
@Test
void export_afterVersionModified_shouldUseOriginalFile() throws Exception {
byte[] contentA = pdf("AAA");
byte[] contentB = pdf("BBB");
MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", contentA);
MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", contentB);
ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA);
ResumeExportTask task = resumeExportService.createExportTask(userId, v1.getId()); // 快照 = A 的路径
// 创建导出后立刻修改版本(替换文件)→ 旧文件 A 若被进行中的任务引用则保留
resumeVersionService.updateVersion(userId, v1.getId(), "renamed", fileB);
ResumeExportTask done = awaitTerminal(task.getId());
assertEquals(ResumeExportTask.STATUS_SUCCESS, done.getStatus(), "导出应成功: " + done.getErrorMessage());
assertArrayEquals(contentA, resumeExportStorageService.read(done.getOutputPath()));
assertEquals("r.pdf", done.getOutputName());
}
private ResumeExportTask awaitTerminal(Long taskId) throws InterruptedException {
for (int i = 0; i < 100; i++) {
ResumeExportTask t = taskRepository.findById(taskId).orElseThrow();
if (!ResumeExportTask.STATUS_PENDING.equals(t.getStatus())
&& !ResumeExportTask.STATUS_RUNNING.equals(t.getStatus())) {
return t;
}
Thread.sleep(50);
}
throw new AssertionError("export task did not reach terminal state: " + taskId);
}
/**
* 模拟事务 afterCompletion(STATUS_ROLLED_BACK):替换版本文件后强制回滚。
* 断言:新文件被清理、旧文件仍存在、数据库记录未被错误修改。
*/
@Test
void updateVersion_outerRollback_shouldCleanNewFileAndKeepOldFileAndDb() throws Exception {
MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("AAA"));
MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", pdf("BBB"));
ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA);
String pathA = v1.getFileStoragePath();
List<String> before = listMyResumeFiles();
// 事务内更新版本文件,随后强制回滚 → 触发 afterCompletion(STATUS_ROLLED_BACK)
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus tx = transactionManager.getTransaction(def);
try {
resumeVersionService.updateVersion(userId, v1.getId(), null, fileB);
tx.setRollbackOnly();
} finally {
transactionManager.rollback(tx);
}
// 新文件被清理,旧文件仍在(文件集合不变)
List<String> after = listMyResumeFiles();
assertEquals(before, after, "回滚后:新文件应被清理、旧文件仍存在");
// 数据库记录未被错误修改(仍指向旧文件)
ResumeVersion reloaded = versionRepository.findById(v1.getId()).orElseThrow();
assertEquals(pathA, reloaded.getFileStoragePath());
}
/** 列出当前测试用户(userId)前缀的简历存储文件 */
private List<String> listMyResumeFiles() throws IOException {
Path dir = Paths.get(storageDir).toAbsolutePath().normalize();
try (var stream = Files.list(dir)) {
return stream
.filter(p -> p.getFileName().toString().startsWith(userId + "_"))
.map(p -> p.getFileName().toString())
.sorted()
.collect(Collectors.toList());
}
}
private void runWithLatch(CountDownLatch start, Runnable action, AtomicReference<Throwable> failure) {
try {
start.await();
action.run();
} catch (Throwable t) {
failure.compareAndSet(null, t);
}
}
private byte[] pdf(String content) {
return ("%PDF-1.7\n" + content + "\n%%EOF\n")
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
}
}