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