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
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);
|
|
}
|
|
}
|
|
|