|
@@ -0,0 +1,289 @@
|
|
|
|
|
+package com.emoon.okr.integration;
|
|
|
|
|
+
|
|
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
+import com.emoon.okr.entity.AssessmentPeriod;
|
|
|
|
|
+import com.emoon.okr.entity.PeriodParticipant;
|
|
|
|
|
+import com.emoon.okr.entity.SysDepartment;
|
|
|
|
|
+import com.emoon.okr.entity.SysUser;
|
|
|
|
|
+import com.emoon.okr.enums.PeriodStatus;
|
|
|
|
|
+import com.emoon.okr.enums.PeriodType;
|
|
|
|
|
+import com.emoon.okr.enums.UserRole;
|
|
|
|
|
+import com.emoon.okr.mapper.AssessmentPeriodMapper;
|
|
|
|
|
+import com.emoon.okr.mapper.PeriodParticipantMapper;
|
|
|
|
|
+import com.emoon.okr.mapper.SysDepartmentMapper;
|
|
|
|
|
+import com.emoon.okr.mapper.SysUserMapper;
|
|
|
|
|
+import com.emoon.okr.security.JwtTokenProvider;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
|
|
|
|
+import org.springframework.boot.test.context.SpringBootTest;
|
|
|
|
|
+import org.springframework.http.MediaType;
|
|
|
|
|
+import org.springframework.security.crypto.password.PasswordEncoder;
|
|
|
|
|
+import org.springframework.test.annotation.DirtiesContext;
|
|
|
|
|
+import org.springframework.test.context.TestPropertySource;
|
|
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
|
|
+
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
|
|
+import java.time.LocalDateTime;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
|
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
|
|
+
|
|
|
|
|
+@SpringBootTest
|
|
|
|
|
+@AutoConfigureMockMvc
|
|
|
|
|
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
|
|
|
|
+@TestPropertySource(properties = {
|
|
|
|
|
+ "spring.datasource.url=jdbc:sqlite:file:peer_review_api_lifecycle?mode=memory&cache=shared",
|
|
|
|
|
+ "app.seed.demo-data-enabled=false"
|
|
|
|
|
+})
|
|
|
|
|
+class PeerReviewLifecycleApiIntegrationTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired MockMvc mockMvc;
|
|
|
|
|
+ @Autowired ObjectMapper objectMapper;
|
|
|
|
|
+ @Autowired JwtTokenProvider tokenProvider;
|
|
|
|
|
+ @Autowired PasswordEncoder passwordEncoder;
|
|
|
|
|
+ @Autowired AssessmentPeriodMapper periodMapper;
|
|
|
|
|
+ @Autowired PeriodParticipantMapper participantMapper;
|
|
|
|
|
+ @Autowired SysDepartmentMapper departmentMapper;
|
|
|
|
|
+ @Autowired SysUserMapper userMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void completesPeerReviewLifecycleWithoutLeakingResponses() throws Exception {
|
|
|
|
|
+ SysDepartment product = department("产品部");
|
|
|
|
|
+ SysDepartment delivery = department("交付部");
|
|
|
|
|
+ SysUser admin = user("环评管理员", UserRole.HR_ADMIN, null, null);
|
|
|
|
|
+ SysUser leader = user("冻结Leader", UserRole.DEPT_LEADER, product.getId(), null);
|
|
|
|
|
+ SysUser subject = user("被评价人", UserRole.EMPLOYEE, product.getId(), leader.getId());
|
|
|
|
|
+ SysUser sameDept = user("同部门同事", UserRole.EMPLOYEE, product.getId(), leader.getId());
|
|
|
|
|
+ SysUser crossDept = user("跨部门同事", UserRole.EMPLOYEE, delivery.getId(), null);
|
|
|
|
|
+ SysUser declining = user("婉拒同事", UserRole.EMPLOYEE, delivery.getId(), null);
|
|
|
|
|
+ SysUser replacement = user("补邀同事", UserRole.EMPLOYEE, delivery.getId(), null);
|
|
|
|
|
+ SysUser directReport = user("直属下属", UserRole.EMPLOYEE, product.getId(), subject.getId());
|
|
|
|
|
+ SysUser newLeader = user("实时新Leader", UserRole.DEPT_LEADER, product.getId(), null);
|
|
|
|
|
+
|
|
|
|
|
+ AssessmentPeriod period = assessingPeriod();
|
|
|
|
|
+ periodMapper.insert(period);
|
|
|
|
|
+ participant(period, subject, leader.getId(), leader.getId());
|
|
|
|
|
+ participant(period, leader, null, null);
|
|
|
|
|
+ participant(period, sameDept, leader.getId(), null);
|
|
|
|
|
+ participant(period, crossDept, null, null);
|
|
|
|
|
+ participant(period, declining, null, null);
|
|
|
|
|
+ participant(period, replacement, null, null);
|
|
|
|
|
+ participant(period, directReport, subject.getId(), null);
|
|
|
|
|
+
|
|
|
|
|
+ String adminToken = token(admin);
|
|
|
|
|
+ String leaderToken = token(leader);
|
|
|
|
|
+ String subjectToken = token(subject);
|
|
|
|
|
+ String sameToken = token(sameDept);
|
|
|
|
|
+ String crossToken = token(crossDept);
|
|
|
|
|
+ String decliningToken = token(declining);
|
|
|
|
|
+ String newLeaderToken = token(newLeader);
|
|
|
|
|
+
|
|
|
|
|
+ postData(subjectToken, "/api/scores/self", scoreBody(period.getId(), null));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode candidates = getData(subjectToken,
|
|
|
|
|
+ "/api/peer-reviews/my/candidates?periodId=" + period.getId());
|
|
|
|
|
+ List<Long> candidateIds = objectMapper.convertValue(candidates.findValues("userId"),
|
|
|
|
|
+ objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
|
|
|
|
|
+ assertTrue(candidateIds.containsAll(List.of(sameDept.getId(), crossDept.getId(), declining.getId())));
|
|
|
|
|
+ assertFalse(candidateIds.contains(subject.getId()));
|
|
|
|
|
+ assertFalse(candidateIds.contains(leader.getId()));
|
|
|
|
|
+ assertFalse(candidateIds.contains(directReport.getId()));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode request = postData(subjectToken, "/api/peer-reviews/my/requests", Map.of(
|
|
|
|
|
+ "periodId", period.getId(),
|
|
|
|
|
+ "reviewerIds", List.of(sameDept.getId(), crossDept.getId(), declining.getId())
|
|
|
|
|
+ ));
|
|
|
|
|
+ long requestId = request.get("requestId").asLong();
|
|
|
|
|
+ putData(leaderToken, "/api/peer-reviews/leader/requests/" + requestId + "/review",
|
|
|
|
|
+ Map.of("approved", true));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode leaderSummary = getData(leaderToken, leaderSummaryPath(period, subject));
|
|
|
|
|
+ long sameInvitationId = invitationId(leaderSummary, sameDept.getId());
|
|
|
|
|
+ long crossInvitationId = invitationId(leaderSummary, crossDept.getId());
|
|
|
|
|
+ long decliningInvitationId = invitationId(leaderSummary, declining.getId());
|
|
|
|
|
+
|
|
|
|
|
+ postData(decliningToken, "/api/peer-reviews/invitations/" + decliningInvitationId + "/decline",
|
|
|
|
|
+ Map.of("reason", "本周期合作接触不足"));
|
|
|
|
|
+ postData(subjectToken, "/api/peer-reviews/my/supplements", Map.of(
|
|
|
|
|
+ "periodId", period.getId(), "reviewerId", replacement.getId()));
|
|
|
|
|
+ putData(leaderToken, "/api/peer-reviews/leader/requests/" + requestId + "/review",
|
|
|
|
|
+ Map.of("approved", true));
|
|
|
|
|
+
|
|
|
|
|
+ postData(sameToken, "/api/peer-reviews/invitations/" + sameInvitationId + "/accept", Map.of());
|
|
|
|
|
+ putData(sameToken, "/api/peer-reviews/invitations/" + sameInvitationId + "/submit",
|
|
|
|
|
+ response(4, 5, 4, "跨部门项目响应及时并主动同步风险", "可以增加阶段性复盘和书面沉淀"));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode blocked = postApi(leaderToken, "/api/scores/superior",
|
|
|
|
|
+ scoreBody(period.getId(), subject.getId()));
|
|
|
|
|
+ assertEquals(500, blocked.get("code").asInt());
|
|
|
|
|
+
|
|
|
|
|
+ postData(crossToken, "/api/peer-reviews/invitations/" + crossInvitationId + "/accept", Map.of());
|
|
|
|
|
+ putData(crossToken, "/api/peer-reviews/invitations/" + crossInvitationId + "/submit",
|
|
|
|
|
+ response(5, 4, 4, "目标推进稳定且交付质量符合预期", "复杂任务开始前可以更早对齐依赖"));
|
|
|
|
|
+
|
|
|
|
|
+ period.setPeerReviewDeadline(LocalDateTime.now().minusMinutes(1));
|
|
|
|
|
+ periodMapper.updateById(period);
|
|
|
|
|
+ postData(leaderToken, "/api/scores/superior", scoreBody(period.getId(), subject.getId()));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode employeeProgress = getData(subjectToken,
|
|
|
|
|
+ "/api/peer-reviews/my/progress?periodId=" + period.getId());
|
|
|
|
|
+ assertEquals(2, employeeProgress.get("submittedCount").asInt());
|
|
|
|
|
+ assertFalse(hasAnyField(employeeProgress, "goalContributionScore", "collaborationScore",
|
|
|
|
|
+ "accountabilityScore", "strengths", "improvementSuggestion"));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode forbiddenWorkspace = getApi(crossToken,
|
|
|
|
|
+ "/api/peer-reviews/invitations/" + sameInvitationId);
|
|
|
|
|
+ assertEquals(403, forbiddenWorkspace.get("code").asInt());
|
|
|
|
|
+
|
|
|
|
|
+ subject.setSuperiorId(newLeader.getId());
|
|
|
|
|
+ userMapper.updateById(subject);
|
|
|
|
|
+ JsonNode realtimeLeaderDenied = getApi(newLeaderToken, leaderSummaryPath(period, subject));
|
|
|
|
|
+ assertEquals(403, realtimeLeaderDenied.get("code").asInt());
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode statistics = getData(adminToken,
|
|
|
|
|
+ "/api/peer-reviews/admin/statistics?periodId=" + period.getId());
|
|
|
|
|
+ assertEquals(2, statistics.get("submittedResponseCount").asInt());
|
|
|
|
|
+ assertFalse(hasAnyField(statistics, "reviewerName", "responses", "strengths", "improvementSuggestion"));
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode finalLeaderSummary = getData(leaderToken, leaderSummaryPath(period, subject));
|
|
|
|
|
+ assertEquals("同部门同事", finalLeaderSummary.get("responses").get(0).get("reviewerName").asText());
|
|
|
|
|
+ assertTrue(finalLeaderSummary.toString().contains("跨部门项目响应及时并主动同步风险"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private AssessmentPeriod assessingPeriod() {
|
|
|
|
|
+ AssessmentPeriod period = new AssessmentPeriod();
|
|
|
|
|
+ period.setName("360 环评生命周期");
|
|
|
|
|
+ period.setPeriodType(PeriodType.MONTHLY);
|
|
|
|
|
+ period.setStartDate(LocalDate.now().minusDays(30));
|
|
|
|
|
+ period.setEndDate(LocalDate.now());
|
|
|
|
|
+ period.setStatus(PeriodStatus.ASSESSING);
|
|
|
|
|
+ period.setPeerInvitationDeadline(LocalDateTime.now().plusDays(1));
|
|
|
|
|
+ period.setPeerReviewDeadline(LocalDateTime.now().plusDays(2));
|
|
|
|
|
+ return period;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void participant(AssessmentPeriod period, SysUser user, Long superiorId, Long evaluatorId) {
|
|
|
|
|
+ PeriodParticipant item = new PeriodParticipant();
|
|
|
|
|
+ item.setPeriodId(period.getId());
|
|
|
|
|
+ item.setUserId(user.getId());
|
|
|
|
|
+ item.setStatus("ACTIVE");
|
|
|
|
|
+ item.setDepartmentIdSnapshot(user.getDepartmentId());
|
|
|
|
|
+ item.setSuperiorIdSnapshot(superiorId);
|
|
|
|
|
+ item.setRoleSnapshot(user.getRole().name());
|
|
|
|
|
+ item.setEvaluatorId(evaluatorId);
|
|
|
|
|
+ participantMapper.insert(item);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private SysDepartment department(String name) {
|
|
|
|
|
+ SysDepartment department = new SysDepartment();
|
|
|
|
|
+ department.setName(name + System.nanoTime());
|
|
|
|
|
+ department.setParentId(0L);
|
|
|
|
|
+ departmentMapper.insert(department);
|
|
|
|
|
+ return department;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private SysUser user(String realName, UserRole role, Long departmentId, Long superiorId) {
|
|
|
|
|
+ SysUser user = new SysUser();
|
|
|
|
|
+ user.setUsername("peer_" + System.nanoTime());
|
|
|
|
|
+ user.setPasswordHash(passwordEncoder.encode("Passw0rd!"));
|
|
|
|
|
+ user.setRealName(realName);
|
|
|
|
|
+ user.setRole(role);
|
|
|
|
|
+ user.setDepartmentId(departmentId);
|
|
|
|
|
+ user.setSuperiorId(superiorId);
|
|
|
|
|
+ user.setStatus("ACTIVE");
|
|
|
|
|
+ userMapper.insert(user);
|
|
|
|
|
+ return user;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> scoreBody(Long periodId, Long userId) {
|
|
|
|
|
+ if (userId == null) {
|
|
|
|
|
+ return Map.of("periodId", periodId, "okrScore", 50, "kpiScore", 30,
|
|
|
|
|
+ "bonus", 0, "penalty", 0, "commentsJson", "{}");
|
|
|
|
|
+ }
|
|
|
|
|
+ return Map.of("periodId", periodId, "userId", userId, "okrScore", 52,
|
|
|
|
|
+ "kpiScore", 32, "bonus", 0, "penalty", 0, "commentsJson", "{}");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> response(int goal, int collaboration, int accountability,
|
|
|
|
|
+ String strengths, String improvement) {
|
|
|
|
|
+ return Map.of(
|
|
|
|
|
+ "goalContributionScore", goal,
|
|
|
|
|
+ "collaborationScore", collaboration,
|
|
|
|
|
+ "accountabilityScore", accountability,
|
|
|
|
|
+ "strengths", strengths,
|
|
|
|
|
+ "improvementSuggestion", improvement
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long invitationId(JsonNode summary, Long reviewerId) {
|
|
|
|
|
+ for (JsonNode invitation : summary.get("invitations")) {
|
|
|
|
|
+ if (invitation.get("reviewerId").asLong() == reviewerId) {
|
|
|
|
|
+ return invitation.get("invitationId").asLong();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new AssertionError("Invitation not found for reviewer " + reviewerId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String leaderSummaryPath(AssessmentPeriod period, SysUser subject) {
|
|
|
|
|
+ return "/api/peer-reviews/leader/summary?periodId=" + period.getId() + "&userId=" + subject.getId();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasAnyField(JsonNode node, String... fields) {
|
|
|
|
|
+ for (String field : fields) {
|
|
|
|
|
+ if (!node.findValues(field).isEmpty()) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode postData(String token, String path, Object body) throws Exception {
|
|
|
|
|
+ JsonNode response = postApi(token, path, body);
|
|
|
|
|
+ assertEquals(200, response.get("code").asInt(), response.toString());
|
|
|
|
|
+ return response.get("data");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode postApi(String token, String path, Object body) throws Exception {
|
|
|
|
|
+ String response = mockMvc.perform(post(path)
|
|
|
|
|
+ .header("Authorization", "Bearer " + token)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content(objectMapper.writeValueAsString(body)))
|
|
|
|
|
+ .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
|
+ return objectMapper.readTree(response);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode putData(String token, String path, Object body) throws Exception {
|
|
|
|
|
+ String response = mockMvc.perform(put(path)
|
|
|
|
|
+ .header("Authorization", "Bearer " + token)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content(objectMapper.writeValueAsString(body)))
|
|
|
|
|
+ .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
|
+ JsonNode api = objectMapper.readTree(response);
|
|
|
|
|
+ assertEquals(200, api.get("code").asInt(), api.toString());
|
|
|
|
|
+ return api.get("data");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode getData(String token, String path) throws Exception {
|
|
|
|
|
+ JsonNode response = getApi(token, path);
|
|
|
|
|
+ assertEquals(200, response.get("code").asInt(), response.toString());
|
|
|
|
|
+ return response.get("data");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode getApi(String token, String path) throws Exception {
|
|
|
|
|
+ String response = mockMvc.perform(get(path).header("Authorization", "Bearer " + token))
|
|
|
|
|
+ .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
|
|
+ return objectMapper.readTree(response);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String token(SysUser user) {
|
|
|
|
|
+ return tokenProvider.generateAccessToken(user.getId(), user.getUsername(), user.getRole().name());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|