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 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 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 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 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 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 after = listMyResumeFiles(); assertEquals(before, after, "回滚后:新文件应被清理、旧文件仍存在"); // 数据库记录未被错误修改(仍指向旧文件) ResumeVersion reloaded = versionRepository.findById(v1.getId()).orElseThrow(); assertEquals(pathA, reloaded.getFileStoragePath()); } /** 列出当前测试用户(userId)前缀的简历存储文件 */ private List 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 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); } }