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.
 
 
 

296 lines
15 KiB

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)。
*
* <p>覆盖身份校验、活跃答卷重复 9708、幂等冲突 9709、答案不属于本答卷 9705、
* 版本并发 9707、29 题提交失败 9706、重复提交幂等返回原结果。</p>
*/
@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<AssessmentQuestion> bank = new ArrayList<>();
Map<String, Integer> perDim = new LinkedHashMap<>(
Map.of("R", 3, "I", 5, "A", 5, "S", 5, "E", 5, "C", 5));
long seq = 1;
for (Map.Entry<String, Integer> 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());
}
}