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