10 changed files with 881 additions and 2 deletions
@ -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"); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue