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.
 
 
 

378 lines
18 KiB

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