Browse Source

feat: add student onboarding checklist APIs

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
037ddc79d7
  1. 6
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  2. 6
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  3. 67
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java
  4. 244
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java
  5. 28
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java
  6. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java
  7. 15
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java
  8. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java
  9. 166
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java
  10. 305
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java

6
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -170,7 +170,11 @@ public enum ErrorCode {
REPORT_REASON_REQUIRED(9607, "report reason is required"), REPORT_REASON_REQUIRED(9607, "report reason is required"),
CANNOT_BLOCK_SELF(9608, "cannot block yourself"), CANNOT_BLOCK_SELF(9608, "cannot block yourself"),
REPORT_RATE_LIMITED(9609, "report rate limited"), REPORT_RATE_LIMITED(9609, "report rate limited"),
BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"); BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"),
// 入职清单 97xx
ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(9701, "onboarding checklist item not found"),
ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently");
private final int code; private final int code;
private final String message; private final String message;

6
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -76,7 +76,8 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 冲突 → 409 // 冲突 → 409
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -163,6 +164,9 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode()
|| errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT; return HttpStatus.CONFLICT;
// 入职进度并发冲突 → 409
if (errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode())
return HttpStatus.CONFLICT;
// 举报限流 → 429 // 举报限流 → 429
if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode())
return HttpStatus.TOO_MANY_REQUESTS; return HttpStatus.TOO_MANY_REQUESTS;

67
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java

@ -0,0 +1,67 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态")
@RestController
@RequestMapping("/onboarding")
@RequiredArgsConstructor
public class OnboardingChecklistController {
private final OnboardingChecklistService onboardingChecklistService;
@Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果")
@GetMapping("/companies/{companyId}/checklist")
public ResponseEntity<ApiResponse<EffectiveChecklistResponse>> getEffectiveChecklist(
@PathVariable Long companyId,
HttpServletRequest request) {
Long userId = resolveUserId();
EffectiveChecklistResponse data =
onboardingChecklistService.getEffectiveChecklist(userId, companyId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "更新清单条目完成状态", description = "使用绝对状态语义设置当前学生的完成状态")
@PutMapping("/companies/{companyId}/checklist/items/{itemId}/completion")
public ResponseEntity<ApiResponse<ProgressUpdateResponse>> updateCompletion(
@PathVariable Long companyId,
@PathVariable Long itemId,
@Valid @RequestBody ProgressUpdateRequest body,
HttpServletRequest request) {
Long userId = resolveUserId();
ProgressUpdateResponse data = onboardingChecklistService.updateCompletion(
userId, companyId, itemId, body);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(authentication.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

244
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java

@ -0,0 +1,244 @@
package com.mydreamjob.onboarding;
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.ChecklistItemView;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class OnboardingChecklistService {
private static final String STANDARD_SOURCE = "STANDARD";
private static final String COMPANY_SOURCE = "COMPANY";
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final OnboardingChecklistItemRepository checklistItemRepository;
private final StudentOnboardingProgressRepository progressRepository;
@Transactional(readOnly = true)
public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) {
assertStudentRole(userId);
Company company = companyRepository.findById(companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
List<OnboardingChecklistItem> items = sortedItems(
checklistItemRepository.findEffectiveChecklistItems(companyId));
List<StudentOnboardingProgress> progressRows = loadProgress(userId, items);
return toEffectiveChecklistResponse(company, items, progressRows);
}
@Transactional
public ProgressUpdateResponse updateCompletion(
Long userId,
Long companyId,
Long itemId,
ProgressUpdateRequest request) {
assertStudentRole(userId);
if (request == null || request.getCompleted() == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
companyRepository.findById(companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
List<OnboardingChecklistItem> items = sortedItems(
checklistItemRepository.findEffectiveChecklistItems(companyId));
OnboardingChecklistItem item = items.stream()
.filter(candidate -> Objects.equals(candidate.getId(), itemId))
.findFirst()
.orElseThrow(() -> new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND));
List<StudentOnboardingProgress> progressRows =
new ArrayList<>(loadProgress(userId, items));
StudentOnboardingProgress current =
progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null);
boolean desired = Boolean.TRUE.equals(request.getCompleted());
if (current != null) {
boolean actual = Boolean.TRUE.equals(current.getCompleted());
if (actual != desired) {
if (!Objects.equals(request.getVersion(), current.getVersion())) {
throw new BusinessException(
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE);
}
current.setCompleted(desired);
current.setCompletedAt(desired ? LocalDateTime.now() : null);
saveProgress(current);
}
} else if (desired) {
current = StudentOnboardingProgress.builder()
.studentUserId(userId)
.itemId(itemId)
.completed(true)
.completedAt(LocalDateTime.now())
.build();
progressRows.add(current);
saveProgress(current);
}
return toProgressUpdateResponse(item, current, items, progressRows);
}
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles() != null
&& user.getRoles().stream().anyMatch(role -> role == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private List<OnboardingChecklistItem> sortedItems(List<OnboardingChecklistItem> items) {
if (items == null || items.isEmpty()) {
return List.of();
}
List<OnboardingChecklistItem> sorted = new ArrayList<>(items);
sorted.sort(Comparator
.comparing(
OnboardingChecklistItem::getSortOrder,
Comparator.nullsLast(Integer::compareTo))
.thenComparing(
OnboardingChecklistItem::getId,
Comparator.nullsLast(Long::compareTo)));
return sorted;
}
private List<StudentOnboardingProgress> loadProgress(
Long userId, List<OnboardingChecklistItem> items) {
if (items.isEmpty()) {
return List.of();
}
List<Long> itemIds = items.stream()
.map(OnboardingChecklistItem::getId)
.filter(Objects::nonNull)
.toList();
if (itemIds.isEmpty()) {
return List.of();
}
return progressRepository.findByStudentUserIdAndItemIdIn(userId, itemIds);
}
private EffectiveChecklistResponse toEffectiveChecklistResponse(
Company company,
List<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
Map<Long, StudentOnboardingProgress> progressByItemId = progressRows.stream()
.collect(Collectors.toMap(
StudentOnboardingProgress::getItemId,
Function.identity(),
(first, ignored) -> first));
List<ChecklistItemView> views = items.stream()
.map(item -> toChecklistItemView(item, progressByItemId.get(item.getId())))
.toList();
int totalItems = views.size();
int completedItems = completedCount(items, progressRows);
return EffectiveChecklistResponse.builder()
.companyId(company.getId())
.companyName(company.getName())
.customized(items.stream()
.anyMatch(item -> COMPANY_SOURCE.equals(item.getScopeType())))
.totalItems(totalItems)
.completedItems(completedItems)
.completionRate(completionRate(totalItems, completedItems))
.items(views)
.build();
}
private ProgressUpdateResponse toProgressUpdateResponse(
OnboardingChecklistItem item,
StudentOnboardingProgress progress,
List<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
int totalItems = items.size();
int completedItems = completedCount(items, progressRows);
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted());
return ProgressUpdateResponse.builder()
.itemId(item.getId())
.completed(completed)
.completedAt(completed && progress != null ? progress.getCompletedAt() : null)
.progressVersion(progress == null || progress.getVersion() == null
? 0L : progress.getVersion())
.totalItems(totalItems)
.completedItems(completedItems)
.completionRate(completionRate(totalItems, completedItems))
.build();
}
private ChecklistItemView toChecklistItemView(
OnboardingChecklistItem item,
StudentOnboardingProgress progress) {
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted());
return ChecklistItemView.builder()
.itemId(item.getId())
.source(STANDARD_SOURCE.equals(item.getScopeType())
? STANDARD_SOURCE : COMPANY_SOURCE)
.itemKey(item.getItemKey())
.title(item.getTitle())
.description(item.getDescription())
.category(item.getCategory())
.required(item.getRequired())
.sortOrder(item.getSortOrder())
.active(item.getActive())
.completed(completed)
.completedAt(completed && progress != null ? progress.getCompletedAt() : null)
.progressVersion(progress == null || progress.getVersion() == null
? 0L : progress.getVersion())
.build();
}
private int completedCount(
List<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
var effectiveItemIds = items.stream()
.map(OnboardingChecklistItem::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return (int) progressRows.stream()
.filter(progress -> effectiveItemIds.contains(progress.getItemId()))
.filter(progress -> Boolean.TRUE.equals(progress.getCompleted()))
.count();
}
private double completionRate(int totalItems, int completedItems) {
if (totalItems == 0) {
return 0.0;
}
double rawRate = completedItems * 100.0 / totalItems;
return Math.min(100.0, Math.round(rawRate * 10.0) / 10.0);
}
private void saveProgress(StudentOnboardingProgress progress) {
try {
progressRepository.saveAndFlush(progress);
} catch (ObjectOptimisticLockingFailureException ex) {
throw new BusinessException(
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex);
}
}
}

28
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java

@ -0,0 +1,28 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChecklistItemView {
private Long itemId;
private String source;
private String itemKey;
private String title;
private String description;
private String category;
private Boolean required;
private Integer sortOrder;
private Boolean active;
private Boolean completed;
private LocalDateTime completedAt;
private Long progressVersion;
}

23
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java

@ -0,0 +1,23 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EffectiveChecklistResponse {
private Long companyId;
private String companyName;
private Boolean customized;
private Integer totalItems;
private Integer completedItems;
private Double completionRate;
private List<ChecklistItemView> items;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java

@ -0,0 +1,15 @@
package com.mydreamjob.onboarding.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ProgressUpdateRequest {
@NotNull(message = "completed is required")
private Boolean completed;
@Min(value = 0, message = "version must not be negative")
private Long version;
}

23
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java

@ -0,0 +1,23 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProgressUpdateResponse {
private Long itemId;
private Boolean completed;
private LocalDateTime completedAt;
private Long progressVersion;
private Integer totalItems;
private Integer completedItems;
private Double completionRate;
}

166
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java

@ -0,0 +1,166 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.ChecklistItemView;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
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.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class OnboardingChecklistControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@MockBean private OnboardingChecklistService onboardingChecklistService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void getChecklist_shouldReturn200WithStableProgressContract() throws Exception {
when(onboardingChecklistService.getEffectiveChecklist(100L, 7L))
.thenReturn(effectiveChecklistResponse());
mockMvc.perform(get("/onboarding/companies/7/checklist")
.header("Authorization", token())
.header("X-Request-Id", "req-t3-get"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.requestId").value("req-t3-get"))
.andExpect(jsonPath("$.data.companyId").value(7))
.andExpect(jsonPath("$.data.companyName").value("示例企业"))
.andExpect(jsonPath("$.data.customized").value(true))
.andExpect(jsonPath("$.data.totalItems").value(1))
.andExpect(jsonPath("$.data.completedItems").value(1))
.andExpect(jsonPath("$.data.completionRate").value(100.0))
.andExpect(jsonPath("$.data.items[0].itemId").value(11))
.andExpect(jsonPath("$.data.items[0].source").value("STANDARD"))
.andExpect(jsonPath("$.data.items[0].progressVersion").value(2));
verify(onboardingChecklistService).getEffectiveChecklist(100L, 7L);
}
@Test
void updateCompletion_shouldReturn200WithRequestIdentityAndLatestRate() throws Exception {
when(onboardingChecklistService.updateCompletion(
eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class)))
.thenReturn(ProgressUpdateResponse.builder()
.itemId(11L)
.completed(true)
.completedAt(LocalDateTime.of(2026, 9, 16, 10, 30))
.progressVersion(0L)
.totalItems(1)
.completedItems(1)
.completionRate(100.0)
.build());
mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion")
.header("Authorization", token())
.header("X-Request-Id", "req-t3-put")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"completed\":true,\"version\":0}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.requestId").value("req-t3-put"))
.andExpect(jsonPath("$.data.itemId").value(11))
.andExpect(jsonPath("$.data.completed").value(true))
.andExpect(jsonPath("$.data.progressVersion").value(0))
.andExpect(jsonPath("$.data.totalItems").value(1))
.andExpect(jsonPath("$.data.completedItems").value(1))
.andExpect(jsonPath("$.data.completionRate").value(100.0));
verify(onboardingChecklistService).updateCompletion(
eq(100L),
eq(7L),
eq(11L),
argThat(request -> Boolean.TRUE.equals(request.getCompleted())
&& Long.valueOf(0L).equals(request.getVersion())));
}
@Test
void getChecklist_withoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/onboarding/companies/7/checklist"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void getChecklist_nonStudent_shouldReturn403() throws Exception {
when(onboardingChecklistService.getEffectiveChecklist(100L, 7L))
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
mockMvc.perform(get("/onboarding/companies/7/checklist")
.header("Authorization", token()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
}
@Test
void updateCompletion_companyMissing_shouldReturn404() throws Exception {
when(onboardingChecklistService.updateCompletion(
eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class)))
.thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion")
.header("Authorization", token())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"completed\":true,\"version\":0}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode()));
}
private String token() {
return "Bearer " + jwtTokenProvider.generateAccessToken(
100L, "1", Map.of("roles", "STUDENT"));
}
private EffectiveChecklistResponse effectiveChecklistResponse() {
return EffectiveChecklistResponse.builder()
.companyId(7L)
.companyName("示例企业")
.customized(true)
.totalItems(1)
.completedItems(1)
.completionRate(100.0)
.items(List.of(ChecklistItemView.builder()
.itemId(11L)
.source("STANDARD")
.itemKey("standard-onboard")
.title("准备入职材料")
.description("身份证和银行卡")
.category("DOCUMENT")
.required(true)
.sortOrder(10)
.active(true)
.completed(true)
.completedAt(LocalDateTime.of(2026, 9, 16, 10, 30))
.progressVersion(2L)
.build()))
.build();
}
}

305
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java

@ -0,0 +1,305 @@
package com.mydreamjob.onboarding;
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OnboardingChecklistServiceTest {
private static final Long STUDENT_ID = 100L;
private static final Long COMPANY_ID = 7L;
private static final LocalDateTime COMPLETED_AT = LocalDateTime.of(2026, 9, 16, 10, 30);
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@Mock private OnboardingChecklistItemRepository checklistItemRepository;
@Mock private StudentOnboardingProgressRepository progressRepository;
@InjectMocks private OnboardingChecklistService service;
@Test
void getEffectiveChecklist_withoutCompanyItems_shouldReturnStandardFallback() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
OnboardingChecklistItem standard = item(1L, "STANDARD", null, "standard-onboard", 10);
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(standard));
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of());
EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID);
assertThat(response.getCompanyId()).isEqualTo(COMPANY_ID);
assertThat(response.getCompanyName()).isEqualTo("示例企业");
assertThat(response.getCustomized()).isFalse();
assertThat(response.getTotalItems()).isEqualTo(1);
assertThat(response.getCompletedItems()).isZero();
assertThat(response.getCompletionRate()).isEqualTo(0.0);
assertThat(response.getItems()).singleElement().satisfies(view -> {
assertThat(view.getItemId()).isEqualTo(1L);
assertThat(view.getSource()).isEqualTo("STANDARD");
assertThat(view.getItemKey()).isEqualTo("standard-onboard");
assertThat(view.getCompleted()).isFalse();
assertThat(view.getCompletedAt()).isNull();
assertThat(view.getProgressVersion()).isZero();
});
}
@Test
void getEffectiveChecklist_withCompanyItems_shouldMergeSortAndComputeProgress() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
OnboardingChecklistItem standardSecond = item(2L, "STANDARD", null, "standard-second", 10);
OnboardingChecklistItem companyFirst = item(3L, "COMPANY", COMPANY_ID, "company-first", 10);
OnboardingChecklistItem standardThird = item(1L, "STANDARD", null, "standard-third", 20);
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(standardThird, companyFirst, standardSecond));
StudentOnboardingProgress completed = progress(3L, true, COMPLETED_AT, 4L);
StudentOnboardingProgress orphaned = progress(99L, true, COMPLETED_AT, 8L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(completed, orphaned));
EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID);
assertThat(response.getCustomized()).isTrue();
assertThat(response.getTotalItems()).isEqualTo(3);
assertThat(response.getCompletedItems()).isEqualTo(1);
assertThat(response.getCompletionRate()).isEqualTo(33.3);
assertThat(response.getItems()).extracting(view -> view.getItemId())
.containsExactly(2L, 3L, 1L);
assertThat(response.getItems().get(1).getSource()).isEqualTo("COMPANY");
assertThat(response.getItems().get(1).getCompleted()).isTrue();
assertThat(response.getItems().get(1).getCompletedAt()).isEqualTo(COMPLETED_AT);
assertThat(response.getItems().get(1).getProgressVersion()).isEqualTo(4L);
}
@Test
void getEffectiveChecklist_empty_shouldReturnZeroRate() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)).thenReturn(List.of());
EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID);
assertThat(response.getItems()).isEmpty();
assertThat(response.getTotalItems()).isZero();
assertThat(response.getCompletedItems()).isZero();
assertThat(response.getCompletionRate()).isEqualTo(0.0);
assertThat(response.getCustomized()).isFalse();
}
@Test
void getEffectiveChecklist_companyMissing_shouldThrowCompanyNotFound() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode());
verifyNoInteractions(checklistItemRepository, progressRepository);
}
@Test
void getEffectiveChecklist_nonStudent_shouldThrowForbidden() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(
User.builder().id(STUDENT_ID).roles(Set.of(User.Role.COMPANY)).build()));
assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
verifyNoInteractions(companyRepository, checklistItemRepository, progressRepository);
}
@Test
void updateCompletion_firstCompletion_shouldCreateProgressAndReturnRate() {
stubAccessAndOneItem();
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(new ArrayList<>());
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.empty());
when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
ProgressUpdateResponse response = service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(true, 0L));
assertThat(response.getItemId()).isEqualTo(11L);
assertThat(response.getCompleted()).isTrue();
assertThat(response.getCompletedAt()).isNotNull();
assertThat(response.getProgressVersion()).isZero();
assertThat(response.getCompletedItems()).isEqualTo(1);
assertThat(response.getTotalItems()).isEqualTo(1);
assertThat(response.getCompletionRate()).isEqualTo(100.0);
ArgumentCaptor<StudentOnboardingProgress> captor =
ArgumentCaptor.forClass(StudentOnboardingProgress.class);
verify(progressRepository).saveAndFlush(captor.capture());
assertThat(captor.getValue().getStudentUserId()).isEqualTo(STUDENT_ID);
assertThat(captor.getValue().getItemId()).isEqualTo(11L);
assertThat(captor.getValue().getCompleted()).isTrue();
assertThat(captor.getValue().getCompletedAt()).isNotNull();
}
@Test
void updateCompletion_sameStateWithStaleVersion_shouldBeIdempotent() {
stubAccessAndOneItem();
StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(existing));
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.of(existing));
ProgressUpdateResponse response = service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(true, 2L));
assertThat(response.getCompleted()).isTrue();
assertThat(response.getCompletedAt()).isEqualTo(COMPLETED_AT);
assertThat(response.getProgressVersion()).isEqualTo(5L);
assertThat(response.getCompletionRate()).isEqualTo(100.0);
verify(progressRepository, never()).saveAndFlush(any());
}
@Test
void updateCompletion_differentStateWithStaleVersion_shouldReturnConcurrentUpdate() {
stubAccessAndOneItem();
StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(existing));
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(false, 4L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode());
assertThat(existing.getCompleted()).isTrue();
assertThat(existing.getCompletedAt()).isEqualTo(COMPLETED_AT);
verify(progressRepository, never()).saveAndFlush(any());
}
@Test
void updateCompletion_cancel_shouldClearCompletedAt() {
stubAccessAndOneItem();
StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(existing));
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.of(existing));
when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class)))
.thenAnswer(invocation -> {
StudentOnboardingProgress saved = invocation.getArgument(0);
saved.setVersion(6L);
return saved;
});
ProgressUpdateResponse response = service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(false, 5L));
assertThat(response.getCompleted()).isFalse();
assertThat(response.getCompletedAt()).isNull();
assertThat(response.getProgressVersion()).isEqualTo(6L);
assertThat(response.getCompletedItems()).isZero();
assertThat(response.getCompletionRate()).isEqualTo(0.0);
assertThat(existing.getCompleted()).isFalse();
assertThat(existing.getCompletedAt()).isNull();
verify(progressRepository).saveAndFlush(existing);
}
@Test
void updateCompletion_itemOutsideCompanyEffectiveChecklist_shouldReturnNotFound() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(item(11L, "COMPANY", COMPANY_ID, "company-item", 10)));
assertThatThrownBy(() -> service.updateCompletion(
STUDENT_ID, COMPANY_ID, 999L, request(true, 0L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
verifyNoInteractions(progressRepository);
}
private void stubAccessAndOneItem() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(item(11L, "STANDARD", null, "standard-onboard", 10)));
}
private User student() {
return User.builder().id(STUDENT_ID).roles(Set.of(User.Role.STUDENT)).build();
}
private Company company() {
return Company.builder().id(COMPANY_ID).name("示例企业").ownerUserId(900L).build();
}
private OnboardingChecklistItem item(
Long id, String scopeType, Long companyId, String itemKey, int sortOrder) {
return OnboardingChecklistItem.builder()
.id(id)
.scopeType(scopeType)
.scopeId(companyId == null ? 0L : companyId)
.companyId(companyId)
.itemKey(itemKey)
.title(itemKey + "-title")
.description(itemKey + "-description")
.category("OTHER")
.sortOrder(sortOrder)
.required(false)
.active(true)
.build();
}
private StudentOnboardingProgress progress(
Long itemId, boolean completed, LocalDateTime completedAt, Long version) {
return StudentOnboardingProgress.builder()
.id(itemId * 10)
.studentUserId(STUDENT_ID)
.itemId(itemId)
.completed(completed)
.completedAt(completedAt)
.version(version)
.build();
}
private ProgressUpdateRequest request(boolean completed, Long version) {
ProgressUpdateRequest request = new ProgressUpdateRequest();
request.setCompleted(completed);
request.setVersion(version);
return request;
}
}
Loading…
Cancel
Save