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