package com.mydreamjob.assessment; import com.mydreamjob.assessment.dto.AssessmentResultResponse; import com.mydreamjob.assessment.dto.CreateAssessmentAttemptRequest; import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.security.UserRoleGuard; import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; 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.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.springframework.dao.OptimisticLockingFailureException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** * 答卷服务单测(V34/V35)。 * *

覆盖身份校验、活跃答卷重复 9708、幂等冲突 9709、答案不属于本答卷 9705、 * 版本并发 9707、29 题提交失败 9706、重复提交幂等返回原结果。

*/ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class AssessmentAttemptServiceTest { private static final Long STUDENT = 100L; private static final Long COMPANY = 200L; private static final Long ATTEMPT_ID = 1L; @Mock private AssessmentDefinitionRepository definitionRepository; @Mock private AssessmentQuestionRepository questionRepository; @Mock private AssessmentAttemptRepository attemptRepository; @Mock private AssessmentAttemptQuestionRepository attemptQuestionRepository; @Mock private AssessmentAnswerRepository answerRepository; @Mock private AssessmentResultRepository resultRepository; @Mock private AssessmentResultService resultService; @Mock private AssessmentResultComputer resultComputer; @Mock private UserRepository userRepository; @Mock private AuditService auditService; /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ @Mock private UserRoleGuard userRoleGuard; @InjectMocks private AssessmentAttemptService service; private final AuditContext ctx = AuditContext.builder().requestId("req").build(); @BeforeEach void setUp() { // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); when(attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(anyLong())).thenReturn(List.of()); when(answerRepository.findByAttemptId(anyLong())).thenReturn(List.of()); } private User student() { User u = User.builder().id(STUDENT).phone("1").role(User.Role.STUDENT).build(); u.setRoles(Set.of(User.Role.STUDENT)); return u; } private User company() { User u = User.builder().id(COMPANY).phone("2").role(User.Role.COMPANY).build(); u.setRoles(Set.of(User.Role.COMPANY)); return u; } private AssessmentDefinition definition() { return AssessmentDefinition.builder().id(10L).typeCode("HOLLAND_RIASEC").status("ACTIVE") .questionCount(72).attemptQuestionCount(30).minPerDimension(5) .questionSetVersion("riasec-self-v0.2").rulesVersion("riasec-scoring-v0.1").build(); } private AssessmentAttempt attempt(String status) { return AssessmentAttempt.builder().id(ATTEMPT_ID).studentUserId(STUDENT).definitionId(10L) .status(status).selectedQuestionCount(30).questionSetVersion("riasec-self-v0.2") .version(0L).answeredCount(0).build(); } @Test void companyUser_create_returnsForbidden() { when(userRepository.findById(COMPANY)).thenReturn(Optional.of(company())); BusinessException ex = assertThrows(BusinessException.class, () -> service.create(COMPANY, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").build(), ctx)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void create_duplicateActive_returns9708() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(anyLong(), anyLong(), anyList())) .thenReturn(true); BusinessException ex = assertThrows(BusinessException.class, () -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").build(), ctx)); assertEquals(ErrorCode.ASSESSMENT_DUPLICATE_ACTIVE.getCode(), ex.getErrorCodeInt()); } @Test void create_sameKeyDifferentDefinition_returns9709() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); AssessmentAttempt existing = attempt("DRAFT"); existing.setId(99L); existing.setDefinitionId(999L); // 定义不同 → 冲突 when(attemptRepository.findByStudentUserIdAndIdempotencyKey(STUDENT, "k")).thenReturn(Optional.of(existing)); BusinessException ex = assertThrows(BusinessException.class, () -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").idempotencyKey("k").build(), ctx)); assertEquals(ErrorCode.ASSESSMENT_IDEMPOTENCY_CONFLICT.getCode(), ex.getErrorCodeInt()); } @Test void create_sameKeySameDefinition_returnsOriginalAttempt() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); AssessmentAttempt existing = attempt("DRAFT"); existing.setId(99L); when(attemptRepository.findByStudentUserIdAndIdempotencyKey(STUDENT, "k")).thenReturn(Optional.of(existing)); var resp = service.create(STUDENT, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").idempotencyKey("k").build(), ctx); assertEquals(99L, resp.getAttemptId()); verify(attemptRepository, never()).saveAndFlush(any()); } @Test void saveAnswers_questionNotOnAttempt_returns9705() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT"))); when(attemptQuestionRepository.existsByAttemptIdAndQuestionId(ATTEMPT_ID, 999L)).thenReturn(false); SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() .version(0L) .answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() .questionId(999L).value(4).build())) .build(); BusinessException ex = assertThrows(BusinessException.class, () -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); } @Test void saveAnswers_versionMismatch_returns9707() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); AssessmentAttempt a = attempt("DRAFT"); a.setVersion(5L); when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(a)); SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() .version(4L) .answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() .questionId(301L).value(4).build())) .build(); BusinessException ex = assertThrows(BusinessException.class, () -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); assertEquals(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); } @Test void saveAnswers_optimisticLockConflict_returns9707() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT"))); when(attemptQuestionRepository.existsByAttemptIdAndQuestionId(ATTEMPT_ID, 301L)).thenReturn(true); when(answerRepository.findByAttemptIdAndQuestionId(ATTEMPT_ID, 301L)).thenReturn(Optional.empty()); when(answerRepository.countByAttemptId(ATTEMPT_ID)).thenReturn(1L); doThrow(new OptimisticLockingFailureException("stale")) .when(attemptRepository).saveAndFlush(any()); SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() .version(0L) .answers(List.of(SaveAssessmentAnswersRequest.AnswerItem.builder() .questionId(301L).value(4).build())) .build(); BusinessException ex = assertThrows(BusinessException.class, () -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); assertEquals(ErrorCode.ASSESSMENT_CONCURRENT_UPDATE.getCode(), ex.getErrorCodeInt()); } @Test void submit_incomplete_returns9706() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("IN_PROGRESS"))); when(answerRepository.countByAttemptId(ATTEMPT_ID)).thenReturn(29L); BusinessException ex = assertThrows(BusinessException.class, () -> service.submit(STUDENT, ATTEMPT_ID, ctx)); assertEquals(ErrorCode.ASSESSMENT_INCOMPLETE.getCode(), ex.getErrorCodeInt()); } @Test void submit_completed_idempotentReturnsExistingResult() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); AssessmentAttempt a = attempt("COMPLETED"); a.setSubmittedAt(java.time.LocalDateTime.now()); when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(a)); AssessmentResult r = AssessmentResult.builder().attemptId(ATTEMPT_ID).primaryCode("SIC") .primaryType("S").answeredCount(30).build(); when(resultRepository.findByAttemptId(ATTEMPT_ID)).thenReturn(Optional.of(r)); AssessmentResultResponse resp = AssessmentResultResponse.builder().attemptId(ATTEMPT_ID) .primaryCode("SIC").build(); when(resultService.toResponse(a, r)).thenReturn(resp); var out = service.submit(STUDENT, ATTEMPT_ID, ctx); assertEquals("SIC", out.getPrimaryCode()); verify(resultService).toResponse(a, r); verify(auditService, never()).log(anyLong(), eq("ASSESSMENT_ATTEMPT_SUBMITTED"), anyString(), anyLong(), anyString(), any()); } @Test void adminUser_create_returnsForbidden() { User u = User.builder().id(999L).phone("3").role(User.Role.ADMIN).build(); u.setRoles(Set.of(User.Role.ADMIN)); when(userRepository.findById(999L)).thenReturn(Optional.of(u)); BusinessException ex = assertThrows(BusinessException.class, () -> service.create(999L, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").build(), ctx)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @Test void create_inactiveType_returns9701() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of( AssessmentDefinition.builder().id(10L).typeCode("HOLLAND_RIASEC").status("INACTIVE").build())); BusinessException ex = assertThrows(BusinessException.class, () -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").build(), ctx)); assertEquals(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } @Test void create_insufficientBankOnADimension_rejects() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(definitionRepository.findByTypeCode("HOLLAND_RIASEC")).thenReturn(Optional.of(definition())); when(attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(anyLong(), anyLong(), anyList())) .thenReturn(false); // 六维齐全但 R 维度仅 3 道(<5),不能静默减题 List bank = new ArrayList<>(); Map perDim = new LinkedHashMap<>( Map.of("R", 3, "I", 5, "A", 5, "S", 5, "E", 5, "C", 5)); long seq = 1; for (Map.Entry e : perDim.entrySet()) { for (int i = 0; i < e.getValue(); i++) { bank.add(AssessmentQuestion.builder().id(seq++).dimension(e.getKey()).build()); } } when(questionRepository.findByDefinitionIdAndActiveTrueOrderByPositionAsc(10L)).thenReturn(bank); BusinessException ex = assertThrows(BusinessException.class, () -> service.create(STUDENT, CreateAssessmentAttemptRequest.builder() .typeCode("HOLLAND_RIASEC").build(), ctx)); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void saveAnswers_duplicateQuestionIdInSameRequest_returns9705() { when(userRepository.findById(STUDENT)).thenReturn(Optional.of(student())); when(attemptRepository.findById(ATTEMPT_ID)).thenReturn(Optional.of(attempt("DRAFT"))); SaveAssessmentAnswersRequest req = SaveAssessmentAnswersRequest.builder() .version(0L) .answers(List.of( SaveAssessmentAnswersRequest.AnswerItem.builder().questionId(301L).value(4).build(), SaveAssessmentAnswersRequest.AnswerItem.builder().questionId(301L).value(5).build())) .build(); BusinessException ex = assertThrows(BusinessException.class, () -> service.saveAnswers(STUDENT, ATTEMPT_ID, req, ctx)); assertEquals(ErrorCode.ASSESSMENT_ANSWER_INVALID.getCode(), ex.getErrorCodeInt()); } }