package com.mydreamjob.resume; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.ProfileService; import com.mydreamjob.profile.storage.ResumeStorageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.mock.web.MockMultipartFile; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ResumeVersionServiceTest { @Mock private ResumeVersionRepository resumeVersionRepository; @Mock private ResumeExportTaskRepository exportTaskRepository; @Mock private ResumeStorageService resumeStorageService; @Mock private ResumeVersionWriter versionWriter; @Mock private ProfileService profileService; @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private ResumeVersionService resumeVersionService; private static final Long USER_ID = 100L; private static final Long OTHER_USER_ID = 200L; private static final Long VERSION_ID = 1L; private MockMultipartFile file() { return new MockMultipartFile("file", "resume.pdf", "application/pdf", "content".getBytes()); } private ResumeVersion version(String status, boolean isDefault) { return ResumeVersion.builder().id(VERSION_ID).userId(USER_ID).name("秋招版") .versionNo(1).fileStoragePath("100_abc123.pdf").fileName("resume.pdf") .contentType("application/pdf").fileSize(7L) .defaultKey(isDefault ? ResumeVersion.DEFAULT_KEY : null) .status(status).build(); } @BeforeEach void setUp() { // 无:void 方法默认 no-op;需要抛错时在具体测试内 stub } // ── 创建 ────────────────────────────────────────────────── @Test void createVersion_shouldSucceed() { when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_new.pdf"); when(versionWriter.saveNewVersion(eq(USER_ID), eq("秋招版"), eq("100_new.pdf"), any(), any(), anyLong())).thenReturn(version(3, ResumeVersion.STATUS_ACTIVE, false)); ResumeVersion v = resumeVersionService.createVersion(USER_ID, " 秋招版 ", file()); assertEquals(3, v.getVersionNo()); assertEquals("秋招版", v.getName()); assertNull(v.getDefaultKey()); assertEquals(ResumeVersion.STATUS_ACTIVE, v.getStatus()); verify(profileService).validateResumeFile(any()); } private ResumeVersion version(int versionNo, String status, boolean isDefault) { return ResumeVersion.builder().id((long) versionNo).userId(USER_ID).name("秋招版") .versionNo(versionNo).fileStoragePath("100_new.pdf").fileName("resume.pdf") .contentType("application/pdf").fileSize(7L) .defaultKey(isDefault ? ResumeVersion.DEFAULT_KEY : null).status(status).build(); } @Test void createVersion_firstVersion_shouldStartAtOne() { when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_a.pdf"); when(versionWriter.saveNewVersion(eq(USER_ID), isNull(), eq("100_a.pdf"), any(), any(), anyLong())).thenReturn(version(1, ResumeVersion.STATUS_ACTIVE, false)); ResumeVersion v = resumeVersionService.createVersion(USER_ID, null, file()); assertEquals(1, v.getVersionNo()); // 空 name 归一化为 null 后传给 writer verify(versionWriter).saveNewVersion(eq(USER_ID), isNull(), any(), any(), any(), anyLong()); } @Test void createVersion_invalidFile_shouldFail() { doThrow(new BusinessException(ErrorCode.BAD_REQUEST)).when(profileService).validateResumeFile(any()); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.createVersion(USER_ID, "x", file())); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void createVersion_nameTooLong_shouldFail() { String longName = "n".repeat(121); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.createVersion(USER_ID, longName, file())); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void createVersion_nonStudent_shouldFail() { doThrow(new BusinessException(ErrorCode.FORBIDDEN)).when(profileService).assertStudentRole(anyLong()); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.createVersion(USER_ID, "x", file())); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void createVersion_versionNoConflict_shouldRetry() { when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_b.pdf"); // 前两次冲突,第三次成功 when(versionWriter.saveNewVersion(any(), any(), any(), any(), any(), anyLong())) .thenThrow(new DataIntegrityViolationException("dup")) .thenThrow(new DataIntegrityViolationException("dup")) .thenReturn(version(3, ResumeVersion.STATUS_ACTIVE, false)); ResumeVersion v = resumeVersionService.createVersion(USER_ID, "x", file()); assertEquals(3, v.getVersionNo()); verify(versionWriter, times(3)).saveNewVersion(any(), any(), any(), any(), any(), anyLong()); } @Test void createVersion_allRetriesFailed_shouldFail() { when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_c.pdf"); when(versionWriter.saveNewVersion(any(), any(), any(), any(), any(), anyLong())) .thenThrow(new DataIntegrityViolationException("dup")); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.createVersion(USER_ID, "x", file())); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); verify(versionWriter, times(3)).saveNewVersion(any(), any(), any(), any(), any(), anyLong()); // 重试耗尽后清理已保存但未被引用的新文件,避免孤儿文件 verify(resumeStorageService).delete("100_c.pdf"); } // ── 列表 / 详情 ─────────────────────────────────────────── @Test void listVersions_shouldReturnOwnActiveOnly() { when(resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(USER_ID, ResumeVersion.STATUS_ACTIVE)) .thenReturn(List.of(version(ResumeVersion.STATUS_ACTIVE, false))); var list = resumeVersionService.listVersions(USER_ID); assertEquals(1, list.size()); } @Test void getVersion_shouldSucceed() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); assertDoesNotThrow(() -> resumeVersionService.getVersion(USER_ID, VERSION_ID)); } @Test void getVersion_otherUser_shouldReturn403() { // 版本存在但属于他人 → 403 FORBIDDEN when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.getVersion(OTHER_USER_ID, VERSION_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void getVersion_deleted_shouldReturn404() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_DELETED, false))); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.getVersion(USER_ID, VERSION_ID)); assertEquals(ErrorCode.RESUME_VERSION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } // ── 修改 ────────────────────────────────────────────────── @Test void updateVersion_shouldRenameAndReplaceFile() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_replaced.pdf"); when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); ResumeVersion v = resumeVersionService.updateVersion(USER_ID, VERSION_ID, " 新名字 ", file()); assertEquals("新名字", v.getName()); assertEquals("100_replaced.pdf", v.getFileStoragePath()); verify(resumeStorageService).delete("100_abc123.pdf"); } @Test void updateVersion_nameOnly_shouldKeepFile() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); ResumeVersion v = resumeVersionService.updateVersion(USER_ID, VERSION_ID, " 新名字 ", null); assertEquals("新名字", v.getName()); verify(resumeStorageService, never()).store(anyLong(), any()); } // ── 文件一致性 ──────────────────────────────────────────── @Test void updateVersion_storeNewFileFails_oldFileKept() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(resumeStorageService.store(eq(USER_ID), any())) .thenThrow(new RuntimeException("disk full")); assertThrows(RuntimeException.class, () -> resumeVersionService.updateVersion(USER_ID, VERSION_ID, "x", file())); // 新文件保存失败 → 旧文件绝不能被删除 verify(resumeStorageService, never()).delete(any()); } @Test void updateVersion_dbSaveFails_newFileCleaned_oldFileKept() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_replaced.pdf"); when(resumeVersionRepository.save(any())).thenThrow(new RuntimeException("db down")); assertThrows(RuntimeException.class, () -> resumeVersionService.updateVersion(USER_ID, VERSION_ID, "x", file())); // 数据库保存失败 → 清理刚保存的新文件;旧文件保持可用 verify(resumeStorageService).delete("100_replaced.pdf"); verify(resumeStorageService, never()).delete("100_abc123.pdf"); } @Test void updateVersion_replaceFile_withPendingExport_oldFileKept() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(resumeStorageService.store(eq(USER_ID), any())).thenReturn("100_replaced.pdf"); when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // 旧文件正被进行中的导出任务引用(快照)→ 不得删除 when(exportTaskRepository.existsBySourceStoragePathAndStatusIn(eq("100_abc123.pdf"), any())) .thenReturn(true); resumeVersionService.updateVersion(USER_ID, VERSION_ID, "x", file()); verify(resumeStorageService, never()).delete("100_abc123.pdf"); } @Test void updateVersion_otherUser_shouldReturn403() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.updateVersion(OTHER_USER_ID, VERSION_ID, "x", null)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 删除 ────────────────────────────────────────────────── @Test void deleteVersion_nonDefault_shouldSucceed() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(exportTaskRepository.existsByResumeVersionIdAndStatusIn(eq(VERSION_ID), any())).thenReturn(false); resumeVersionService.deleteVersion(USER_ID, VERSION_ID); verify(resumeStorageService).delete("100_abc123.pdf"); verify(resumeVersionRepository).delete(any()); } @Test void deleteVersion_default_shouldFail() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, true))); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.deleteVersion(USER_ID, VERSION_ID)); assertEquals(ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode(), ex.getErrorCodeInt()); verify(resumeVersionRepository, never()).delete(any()); } @Test void deleteVersion_pendingExport_shouldFail() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(exportTaskRepository.existsByResumeVersionIdAndStatusIn(eq(VERSION_ID), any())).thenReturn(true); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.deleteVersion(USER_ID, VERSION_ID)); assertEquals(ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode(), ex.getErrorCodeInt()); } @Test void deleteVersion_dbDeleteFails_originalFileKept() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(exportTaskRepository.existsByResumeVersionIdAndStatusIn(eq(VERSION_ID), any())).thenReturn(false); org.mockito.Mockito.doThrow(new RuntimeException("db down")) .when(resumeVersionRepository).delete(any()); assertThrows(RuntimeException.class, () -> resumeVersionService.deleteVersion(USER_ID, VERSION_ID)); // 数据库删除失败 → 不得删除存储文件 verify(resumeStorageService, never()).delete(any()); } // ── 设置默认 ────────────────────────────────────────────── @Test void setDefault_shouldClearOldAndSetNew() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); ResumeVersion oldDefault = version(99L, ResumeVersion.STATUS_ACTIVE, true); when(resumeVersionRepository.findFirstByUserIdAndDefaultKey(USER_ID, ResumeVersion.DEFAULT_KEY)) .thenReturn(Optional.of(oldDefault)); when(resumeVersionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); ResumeVersion v = resumeVersionService.setDefault(USER_ID, VERSION_ID); assertTrue(v.isDefault()); assertNull(oldDefault.getDefaultKey()); } private ResumeVersion version(Long id, String status, boolean isDefault) { return ResumeVersion.builder().id(id).userId(USER_ID).name("v").versionNo(1) .fileStoragePath("p.pdf").fileName("p.pdf").contentType("application/pdf") .defaultKey(isDefault ? ResumeVersion.DEFAULT_KEY : null).status(status).build(); } @Test void setDefault_alreadyDefault_shouldBeIdempotent() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, true))); ResumeVersion v = resumeVersionService.setDefault(USER_ID, VERSION_ID); assertTrue(v.isDefault()); verify(resumeVersionRepository, never()).save(any()); } @Test void setDefault_concurrentConflict_shouldFail() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); when(resumeVersionRepository.findFirstByUserIdAndDefaultKey(USER_ID, ResumeVersion.DEFAULT_KEY)) .thenReturn(Optional.empty()); when(resumeVersionRepository.save(any())) .thenThrow(new DataIntegrityViolationException("dup")); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.setDefault(USER_ID, VERSION_ID)); assertEquals(ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode(), ex.getErrorCodeInt()); } @Test void setDefault_otherUser_shouldReturn403() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.setDefault(OTHER_USER_ID, VERSION_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void deleteVersion_otherUser_shouldReturn403() { when(resumeVersionRepository.findById(VERSION_ID)) .thenReturn(Optional.of(version(ResumeVersion.STATUS_ACTIVE, false))); BusinessException ex = assertThrows(BusinessException.class, () -> resumeVersionService.deleteVersion(OTHER_USER_ID, VERSION_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); verify(resumeVersionRepository, never()).delete(any()); } }