轻松聘AI开发
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.
 
 
 

255 lines
11 KiB

package com.mydreamjob.profile;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.security.CurrentUserAccessor;
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.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ResumeControllerTest {
@Mock
private ProfileService profileService;
@Mock
private Authentication authentication;
/** R01-A:Controller 身份解析已收束到唯一的 CurrentUserAccessor。 */
@Spy
private CurrentUserAccessor currentUserAccessor = new CurrentUserAccessor();
/** R01-A:requestId 已收束到 RequestIdAccessor。 */
@Spy
private RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
@InjectMocks
private ProfileController controller;
private static final Long STUDENT_ID = 100L;
private static final Long COMPANY_ID = 10L;
private MockHttpServletRequest request;
@BeforeEach
void setUp() {
request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/profile/me/resume");
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private void mockAuth(Long userId) {
when(authentication.isAuthenticated()).thenReturn(true);
when(authentication.getName()).thenReturn(String.valueOf(userId));
}
// ── 未登录 401 ───────────────────────────────────────────
@Test
void uploadResume_unauthenticated_shouldReturn401() {
when(authentication.isAuthenticated()).thenReturn(false);
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
"application/pdf", "data".getBytes());
// R01-A:未登录语义由 CurrentUserAccessor 统一产出
// (InsufficientAuthenticationException 由 GlobalExceptionHandler 映射为 401),
// 不再由 Controller 自己抛 BusinessException(2001)。
assertThrows(InsufficientAuthenticationException.class,
() -> controller.uploadResume(file, request));
}
// ── STUDENT 上传成功 ──────────────────────────────────────
@Test
void uploadResume_asStudent_shouldReturn200() {
mockAuth(STUDENT_ID);
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
"application/pdf", "data".getBytes());
Map<String, Object> view = Map.of("resumeUrl", "path.pdf", "fileName", "r.pdf");
when(profileService.uploadResume(eq(STUDENT_ID), any())).thenReturn(view);
var resp = controller.uploadResume(file, request);
assertEquals(200, resp.getStatusCode().value());
assertNotNull(resp.getBody());
assertEquals(200, resp.getBody().getCode());
}
// ── COMPANY 上传 403 ──────────────────────────────────────
@Test
void uploadResume_asCompany_shouldReturn403() {
mockAuth(COMPANY_ID);
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
"application/pdf", "data".getBytes());
when(profileService.uploadResume(eq(COMPANY_ID), any()))
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
BusinessException ex = assertThrows(BusinessException.class,
() -> controller.uploadResume(file, request));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
}
// ── ADMIN 上传 403 ────────────────────────────────────────
@Test
void uploadResume_asAdmin_shouldReturn403() {
mockAuth(999L);
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
"application/pdf", "data".getBytes());
when(profileService.uploadResume(eq(999L), any()))
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
BusinessException ex = assertThrows(BusinessException.class,
() -> controller.uploadResume(file, request));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
}
// ── 学生下载成功 ──────────────────────────────────────────
@Test
void downloadResume_asStudent_shouldReturn200() {
mockAuth(STUDENT_ID);
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
"my_resume.pdf", "application/pdf", "content".getBytes());
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
var resp = controller.downloadResume(request);
assertEquals(200, resp.getStatusCode().value());
assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType());
}
// ── 学生删除成功 ──────────────────────────────────────────
@Test
void deleteResume_asStudent_shouldReturn200() {
mockAuth(STUDENT_ID);
doNothing().when(profileService).deleteResume(STUDENT_ID);
var resp = controller.deleteResume(request);
assertEquals(200, resp.getStatusCode().value());
assertEquals(200, resp.getBody().getCode());
}
// ── 非法扩展名 400 ────────────────────────────────────────
@Test
void uploadResume_invalidExtension_shouldReturn400() {
mockAuth(STUDENT_ID);
MockMultipartFile file = new MockMultipartFile("file", "r.exe",
"application/x-msdownload", "data".getBytes());
when(profileService.uploadResume(eq(STUDENT_ID), any()))
.thenThrow(new BusinessException(ErrorCode.BAD_REQUEST));
assertThrows(BusinessException.class,
() -> controller.uploadResume(file, request));
}
// ── MIME 不匹配 400 ───────────────────────────────────────
@Test
void uploadResume_mimeTypeMismatch_shouldReturn400() {
mockAuth(STUDENT_ID);
MockMultipartFile file = new MockMultipartFile("file", "r.pdf",
"text/html", "data".getBytes());
when(profileService.uploadResume(eq(STUDENT_ID), any()))
.thenThrow(new BusinessException(ErrorCode.BAD_REQUEST));
assertThrows(BusinessException.class,
() -> controller.uploadResume(file, request));
}
// ── Content-Disposition 文件名 ─────────────────────────
@Test
void downloadResume_englishFilename_shouldReturnContentDisposition() {
mockAuth(STUDENT_ID);
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
"my_resume.pdf", "application/pdf", "data".getBytes());
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
var resp = controller.downloadResume(request);
String cd = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(cd);
assertTrue(cd.contains("my_resume.pdf"),
"CD should contain original filename, got: " + cd);
assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType());
}
@Test
void downloadResume_chineseFilename_shouldReturnUtf8EncodedFilename() {
mockAuth(STUDENT_ID);
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
"张三-简历.pdf", "application/pdf", "data".getBytes());
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
var resp = controller.downloadResume(request);
String cd = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(cd);
assertTrue(cd.contains("filename*=UTF-8''"));
assertTrue(cd.contains("%E5%BC%A0%E4%B8%89")
|| cd.contains("张三"));
}
@Test
void downloadResume_contentTypeShouldMatchStoredType() {
mockAuth(STUDENT_ID);
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
"cv.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"data".getBytes());
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
var resp = controller.downloadResume(request);
assertEquals(MediaType.valueOf(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
resp.getHeaders().getContentType());
}
@Test
void downloadResume_legacyUnsafeFilenameCannotInjectResponseHeader() {
mockAuth(STUDENT_ID);
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
"..\\resume\r\nX-Injected: secret.pdf", "application/pdf", "data".getBytes());
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
var resp = controller.downloadResume(request);
String contentDisposition = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(contentDisposition);
assertFalse(contentDisposition.contains("\r"));
assertFalse(contentDisposition.contains("\n"));
assertFalse(contentDisposition.contains("X-Injected:"));
}
}