diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 1fd35f3..83dc1a6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -174,6 +174,9 @@ public enum ErrorCode { // 入职清单 97xx ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(9701, "onboarding checklist item not found"), + ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(9702, "onboarding checklist item was modified concurrently"), + ONBOARDING_ITEM_LIMIT_EXCEEDED(9703, "onboarding checklist item limit exceeded"), + ONBOARDING_IDEMPOTENCY_CONFLICT(9704, "idempotency key conflict: request body differs"), ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"); private final int code; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 5047798..0950f19 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -164,8 +164,11 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; - // 入职进度并发冲突 → 409 - if (errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) + // 入职清单并发、上限与幂等冲突 → 409 + if (errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode() + || errorCode == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode() + || errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java index 15c5cd2..4308d9b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java @@ -3,6 +3,9 @@ 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.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; @@ -14,13 +17,19 @@ 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.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + + @Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态") @RestController @RequestMapping("/onboarding") @@ -53,6 +62,50 @@ public class OnboardingChecklistController { return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } + @Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本") + @GetMapping("/company/checklist-items") + public ResponseEntity>> listCompanyChecklistItems( + HttpServletRequest request) { + Long userId = resolveUserId(); + List data = + onboardingChecklistService.listCompanyChecklistItems(userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测") + @PostMapping("/company/checklist-items") + public ResponseEntity> createCompanyChecklistItem( + @Valid @RequestBody CompanyChecklistItemCreateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyChecklistItemView data = + onboardingChecklistService.createCompanyChecklistItem(userId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "更新本企业自定义清单项", description = "使用 version 执行乐观锁控制") + @PutMapping("/company/checklist-items/{itemId}") + public ResponseEntity> updateCompanyChecklistItem( + @PathVariable Long itemId, + @Valid @RequestBody CompanyChecklistItemUpdateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyChecklistItemView data = + onboardingChecklistService.updateCompanyChecklistItem(userId, itemId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "软删除本企业自定义清单项", description = "重复删除保持幂等成功") + @DeleteMapping("/company/checklist-items/{itemId}") + public ResponseEntity> deleteCompanyChecklistItem( + @PathVariable Long itemId, + @RequestParam Long version, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyChecklistItemView data = + onboardingChecklistService.deleteCompanyChecklistItem(userId, itemId, version); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } private Long resolveUserId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !authentication.isAuthenticated()) { @@ -64,4 +117,4 @@ public class OnboardingChecklistController { private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java index 71cb6f8..01aa328 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -7,6 +7,9 @@ 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.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; @@ -15,12 +18,18 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.HexFormat; import java.util.Objects; +import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -30,6 +39,13 @@ public class OnboardingChecklistService { private static final String STANDARD_SOURCE = "STANDARD"; private static final String COMPANY_SOURCE = "COMPANY"; + private static final int DEFAULT_SORT_ORDER = 1000; + private static final int MAX_ACTIVE_COMPANY_ITEMS = 50; + private static final int MAX_TITLE_LENGTH = 120; + private static final int MAX_DESCRIPTION_LENGTH = 1000; + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 64; + private static final Set ALLOWED_CATEGORIES = Set.of( + "DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); private final UserRepository userRepository; private final CompanyRepository companyRepository; @@ -101,6 +117,261 @@ public class OnboardingChecklistService { return toProgressUpdateResponse(item, current, items, progressRows); } + @Transactional(readOnly = true) + public List listCompanyChecklistItems(Long userId) { + Company company = requireCompanyOwner(userId); + return ownedCompanyItems(checklistItemRepository.findAll(), company.getId()).stream() + .sorted(Comparator + .comparing(OnboardingChecklistItem::getSortOrder, + Comparator.nullsLast(Integer::compareTo)) + .thenComparing(OnboardingChecklistItem::getId, + Comparator.nullsLast(Long::compareTo))) + .map(this::toCompanyChecklistItemView) + .toList(); + } + + @Transactional + public CompanyChecklistItemView createCompanyChecklistItem( + Long userId, + CompanyChecklistItemCreateRequest request) { + Company company = requireCompanyOwner(userId); + validateCompanyItemCreateRequest(request); + + String title = normalizeOptional(request.getTitle()); + String description = normalizeOptional(request.getDescription()); + String category = request.getCategory(); + int sortOrder = request.getSortOrder() == null + ? DEFAULT_SORT_ORDER : request.getSortOrder(); + boolean required = Boolean.TRUE.equals(request.getRequired()); + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + String requestHash = hashCompanyItemRequest( + title, description, category, sortOrder, required); + + List companyItems = + ownedCompanyItems(checklistItemRepository.findAll(), company.getId()); + if (idempotencyKey != null) { + OnboardingChecklistItem existing = companyItems.stream() + .filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey)) + .findFirst() + .orElse(null); + if (existing != null) { + if (Objects.equals(existing.getRequestHash(), requestHash)) { + return toCompanyChecklistItemView(existing); + } + throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT); + } + } + + long activeCount = companyItems.stream() + .filter(item -> Boolean.TRUE.equals(item.getActive())) + .count(); + if (activeCount >= MAX_ACTIVE_COMPANY_ITEMS) { + throw new BusinessException(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED); + } + + OnboardingChecklistItem item = OnboardingChecklistItem.builder() + .scopeType(COMPANY_SOURCE) + .scopeId(company.getId()) + .companyId(company.getId()) + .itemKey("company-" + company.getId() + "-" + UUID.randomUUID()) + .title(title) + .description(description) + .category(category) + .sortOrder(sortOrder) + .required(required) + .active(true) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .build(); + return toCompanyChecklistItemView(saveCompanyItem(item)); + } + + @Transactional + public CompanyChecklistItemView updateCompanyChecklistItem( + Long userId, + Long itemId, + CompanyChecklistItemUpdateRequest request) { + Company company = requireCompanyOwner(userId); + validateCompanyItemUpdateRequest(request); + OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); + if (!Objects.equals(item.getVersion(), request.getVersion())) { + throw new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); + } + + item.setTitle(normalizeOptional(request.getTitle())); + item.setDescription(normalizeOptional(request.getDescription())); + item.setCategory(request.getCategory()); + item.setSortOrder(request.getSortOrder()); + item.setRequired(request.getRequired()); + item.setActive(request.getActive()); + return toCompanyChecklistItemView(saveCompanyItem(item)); + } + + @Transactional + public CompanyChecklistItemView deleteCompanyChecklistItem( + Long userId, + Long itemId, + Long version) { + Company company = requireCompanyOwner(userId); + OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); + if (Boolean.TRUE.equals(item.getActive())) { + if (!Objects.equals(item.getVersion(), version)) { + throw new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); + } + item.setActive(false); + item = saveCompanyItem(item); + } + return toCompanyChecklistItemView(item); + } + + private Company requireCompanyOwner(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isCompany = user.getRoles() != null + && user.getRoles().stream().anyMatch(role -> role == User.Role.COMPANY); + if (!isCompany) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + } + + private List ownedCompanyItems( + List items, + Long companyId) { + if (items == null || items.isEmpty()) { + return List.of(); + } + return items.stream() + .filter(item -> belongsToCompany(item, companyId)) + .toList(); + } + + private boolean belongsToCompany(OnboardingChecklistItem item, Long companyId) { + return item != null + && COMPANY_SOURCE.equals(item.getScopeType()) + && Objects.equals(item.getScopeId(), companyId) + && Objects.equals(item.getCompanyId(), companyId); + } + + private OnboardingChecklistItem requireOwnedCompanyItem(Long itemId, Long companyId) { + return checklistItemRepository.findById(itemId) + .filter(item -> belongsToCompany(item, companyId)) + .orElseThrow(() -> new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); + } + + private void validateCompanyItemCreateRequest(CompanyChecklistItemCreateRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String title = normalizeOptional(request.getTitle()); + if (title == null || title.length() > MAX_TITLE_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String description = normalizeOptional(request.getDescription()); + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!ALLOWED_CATEGORIES.contains(request.getCategory())) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + Integer sortOrder = request.getSortOrder(); + if (sortOrder != null && (sortOrder < 0 || sortOrder > 9999)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + if (idempotencyKey != null && idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private void validateCompanyItemUpdateRequest(CompanyChecklistItemUpdateRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String title = normalizeOptional(request.getTitle()); + if (title == null || title.length() > MAX_TITLE_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String description = normalizeOptional(request.getDescription()); + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!ALLOWED_CATEGORIES.contains(request.getCategory()) + || request.getSortOrder() == null + || request.getSortOrder() < 0 + || request.getSortOrder() > 9999 + || request.getRequired() == null + || request.getActive() == null + || request.getVersion() == null + || request.getVersion() < 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeIdempotencyKey(String value) { + return normalizeOptional(value); + } + + private String hashCompanyItemRequest( + String title, + String description, + String category, + int sortOrder, + boolean required) { + String canonical = "title=" + title.length() + ":" + title + + "|description=" + (description == null + ? "null" : description.length() + ":" + description) + + "|category=" + category + + "|sortOrder=" + sortOrder + + "|required=" + required; + return sha256(canonical); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 not available", ex); + } + } + + private CompanyChecklistItemView toCompanyChecklistItemView(OnboardingChecklistItem item) { + return CompanyChecklistItemView.builder() + .itemId(item.getId()) + .source(COMPANY_SOURCE) + .itemKey(item.getItemKey()) + .title(item.getTitle()) + .description(item.getDescription()) + .category(item.getCategory()) + .required(item.getRequired()) + .sortOrder(item.getSortOrder()) + .active(item.getActive()) + .version(item.getVersion() == null ? 0L : item.getVersion()) + .build(); + } + + private OnboardingChecklistItem saveCompanyItem(OnboardingChecklistItem item) { + try { + return checklistItemRepository.saveAndFlush(item); + } catch (ObjectOptimisticLockingFailureException ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ex); + } + } + private void assertStudentRole(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java new file mode 100644 index 0000000..a3db1cb --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java @@ -0,0 +1,34 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyChecklistItemCreateRequest { + + @NotBlank(message = "title is required") + @Size(max = 120, message = "title must be at most 120 chars") + private String title; + + @Size(max = 1000, message = "description must be at most 1000 chars") + private String description; + + @NotBlank(message = "category is required") + @Pattern( + regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", + message = "category is invalid") + private String category; + + @Min(value = 0, message = "sortOrder must not be negative") + @Max(value = 9999, message = "sortOrder must be at most 9999") + private Integer sortOrder = 1000; + + private Boolean required = false; + + @Size(max = 64, message = "idempotencyKey must be at most 64 chars") + private String idempotencyKey; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java new file mode 100644 index 0000000..26e4e51 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java @@ -0,0 +1,41 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyChecklistItemUpdateRequest { + + @NotBlank(message = "title is required") + @Size(max = 120, message = "title must be at most 120 chars") + private String title; + + @Size(max = 1000, message = "description must be at most 1000 chars") + private String description; + + @NotBlank(message = "category is required") + @Pattern( + regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", + message = "category is invalid") + private String category; + + @NotNull(message = "sortOrder is required") + @Min(value = 0, message = "sortOrder must not be negative") + @Max(value = 9999, message = "sortOrder must be at most 9999") + private Integer sortOrder; + + @NotNull(message = "required is required") + private Boolean required; + + @NotNull(message = "active is required") + private Boolean active; + + @NotNull(message = "version is required") + @Min(value = 0, message = "version must not be negative") + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java new file mode 100644 index 0000000..18167a6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java @@ -0,0 +1,24 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompanyChecklistItemView { + + 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 Long version; +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java new file mode 100644 index 0000000..8bb966c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java @@ -0,0 +1,277 @@ +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.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +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.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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class OnboardingCompanyChecklistControllerTest { + + private static final Long COMPANY_USER_ID = 900L; + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private OnboardingChecklistService onboardingChecklistService; + @MockBean private RedisTemplate redisTemplate; + + @Test + void listCompanyItems_shouldReturnCurrentCompanyItems() throws Exception { + when(onboardingChecklistService.listCompanyChecklistItems(COMPANY_USER_ID)) + .thenReturn(List.of(view(11L, true, 0L))); + + mockMvc.perform(get("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t4-list")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.requestId").value("req-t4-list")) + .andExpect(jsonPath("$.data[0].itemId").value(11)) + .andExpect(jsonPath("$.data[0].source").value("COMPANY")) + .andExpect(jsonPath("$.data[0].active").value(true)) + .andExpect(jsonPath("$.data[0].version").value(0)); + + verify(onboardingChecklistService).listCompanyChecklistItems(COMPANY_USER_ID); + } + + @Test + void createCompanyItem_shouldReturnCreatedContract() throws Exception { + when(onboardingChecklistService.createCompanyChecklistItem( + eq(COMPANY_USER_ID), any(CompanyChecklistItemCreateRequest.class))) + .thenReturn(view(12L, true, 0L)); + + mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t4-create") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Submit badge", + "description": "Bring a photo", + "category": "ADMIN", + "sortOrder": 200, + "required": false, + "idempotencyKey": "key-1" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.itemId").value(12)) + .andExpect(jsonPath("$.data.source").value("COMPANY")) + .andExpect(jsonPath("$.data.version").value(0)); + + verify(onboardingChecklistService).createCompanyChecklistItem( + eq(COMPANY_USER_ID), + argThat(request -> "Submit badge".equals(request.getTitle()) + && Integer.valueOf(200).equals(request.getSortOrder()) + && "key-1".equals(request.getIdempotencyKey()))); + } + + @Test + void createCompanyItem_withInvalidFields_shouldReturn400BeforeService() throws Exception { + mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": " ", + "description": "ok", + "category": "INVALID", + "sortOrder": 10000, + "idempotencyKey": "key-1" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(onboardingChecklistService, never()) + .createCompanyChecklistItem(any(), any()); + } + + @Test + void createCompanyItem_withIdempotencyConflict_shouldReturn409() throws Exception { + when(onboardingChecklistService.createCompanyChecklistItem( + eq(COMPANY_USER_ID), any(CompanyChecklistItemCreateRequest.class))) + .thenThrow(new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT)); + + mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Submit badge\",\"category\":\"ADMIN\",\"idempotencyKey\":\"same\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode())); + } + + @Test + void updateCompanyItem_shouldPassVersionAndReturnUpdatedView() throws Exception { + when(onboardingChecklistService.updateCompanyChecklistItem( + eq(COMPANY_USER_ID), eq(13L), any(CompanyChecklistItemUpdateRequest.class))) + .thenReturn(view(13L, false, 5L)); + + mockMvc.perform(put("/onboarding/company/checklist-items/13") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated badge", + "description": "New details", + "category": "TRAINING", + "sortOrder": 300, + "required": true, + "active": false, + "version": 4 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.itemId").value(13)) + .andExpect(jsonPath("$.data.active").value(false)) + .andExpect(jsonPath("$.data.version").value(5)); + + verify(onboardingChecklistService).updateCompanyChecklistItem( + eq(COMPANY_USER_ID), + eq(13L), + argThat(request -> "Updated badge".equals(request.getTitle()) + && Long.valueOf(4L).equals(request.getVersion()))); + } + + @Test + void updateCompanyItem_withStaleVersion_shouldReturn409() throws Exception { + when(onboardingChecklistService.updateCompanyChecklistItem( + eq(COMPANY_USER_ID), eq(13L), any(CompanyChecklistItemUpdateRequest.class))) + .thenThrow(new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE)); + + mockMvc.perform(put("/onboarding/company/checklist-items/13") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated badge", + "category": "ADMIN", + "sortOrder": 300, + "required": true, + "active": true, + "version": 2 + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode())); + } + + @Test + void deleteCompanyItem_shouldReturnSoftDeletedView() throws Exception { + when(onboardingChecklistService.deleteCompanyChecklistItem( + COMPANY_USER_ID, 14L, 3L)) + .thenReturn(view(14L, false, 4L)); + + mockMvc.perform(delete("/onboarding/company/checklist-items/14") + .param("version", "3") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t4-delete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t4-delete")) + .andExpect(jsonPath("$.data.itemId").value(14)) + .andExpect(jsonPath("$.data.active").value(false)) + .andExpect(jsonPath("$.data.version").value(4)); + + verify(onboardingChecklistService) + .deleteCompanyChecklistItem(COMPANY_USER_ID, 14L, 3L); + } + + @Test + void updateCompanyItem_forOtherCompanyItem_shouldReturn404() throws Exception { + when(onboardingChecklistService.updateCompanyChecklistItem( + eq(COMPANY_USER_ID), eq(99L), any(CompanyChecklistItemUpdateRequest.class))) + .thenThrow(new BusinessException(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); + + mockMvc.perform(put("/onboarding/company/checklist-items/99") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated badge", + "category": "ADMIN", + "sortOrder": 300, + "required": true, + "active": true, + "version": 2 + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode())); + } + + @Test + void companyChecklist_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/onboarding/company/checklist-items")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void companyChecklist_withNonCompanyRole_shouldReturn403() throws Exception { + when(onboardingChecklistService.listCompanyChecklistItems(COMPANY_USER_ID)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + mockMvc.perform(get("/onboarding/company/checklist-items") + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + COMPANY_USER_ID, "1", Map.of("roles", "STUDENT")); + } + + private CompanyChecklistItemView view(Long itemId, boolean active, Long version) { + return CompanyChecklistItemView.builder() + .itemId(itemId) + .source("COMPANY") + .itemKey("company-7-item-" + itemId) + .title("Submit badge") + .description("Bring a photo") + .category("ADMIN") + .required(false) + .sortOrder(200) + .active(active) + .version(version) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java new file mode 100644 index 0000000..29fc74b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java @@ -0,0 +1,374 @@ +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.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +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.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OnboardingCompanyChecklistServiceTest { + + private static final Long COMPANY_USER_ID = 900L; + private static final Long COMPANY_ID = 7L; + private static final Long OTHER_COMPANY_ID = 8L; + + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private OnboardingChecklistItemRepository checklistItemRepository; + @Mock private StudentOnboardingProgressRepository progressRepository; + @InjectMocks private OnboardingChecklistService service; + + @Test + void listCompanyChecklistItems_shouldReturnOnlyOwnCompanyItemsIncludingInactive() { + stubCompanyOwner(); + when(checklistItemRepository.findAll()).thenReturn(List.of( + item(1L, OTHER_COMPANY_ID, "other", "OTHER", 10, true, 0L, null), + item(2L, COMPANY_ID, "second", "ADMIN", 20, false, 4L, null), + item(3L, COMPANY_ID, "first", "DOCUMENT", 10, true, 2L, null), + OnboardingChecklistItem.builder() + .id(4L).scopeType("STANDARD").scopeId(0L).companyId(null) + .itemKey("standard").title("standard").category("OTHER") + .sortOrder(0).active(true).required(false).build())); + + List result = service.listCompanyChecklistItems(COMPANY_USER_ID); + + assertThat(result).extracting(CompanyChecklistItemView::getItemId) + .containsExactly(3L, 2L); + assertThat(result).allSatisfy(view -> { + assertThat(view.getSource()).isEqualTo("COMPANY"); + assertThat(view.getItemKey()).isNotBlank(); + }); + assertThat(result.get(1).getActive()).isFalse(); + assertThat(result.get(1).getVersion()).isEqualTo(4L); + } + + @Test + void createCompanyChecklistItem_shouldValidateAllInputConstraints() { + stubCompanyOwner(); + List invalid = new ArrayList<>(); + invalid.add(createRequest(" ", null, "ADMIN", 10, false, "key")); + invalid.add(createRequest("title", "x".repeat(1001), "ADMIN", 10, false, "key")); + invalid.add(createRequest("title", null, "INVALID", 10, false, "key")); + invalid.add(createRequest("title", null, "ADMIN", -1, false, "key")); + invalid.add(createRequest("title", null, "ADMIN", 10000, false, "key")); + invalid.add(createRequest("title", null, "ADMIN", 10, false, "x".repeat(65))); + + for (CompanyChecklistItemCreateRequest request : invalid) { + assertThatThrownBy(() -> service.createCompanyChecklistItem(COMPANY_USER_ID, request)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + } + verifyNoInteractions(checklistItemRepository); + } + + @Test + void createCompanyChecklistItem_shouldPersistNormalizedItemAndStableHash() { + stubCompanyOwner(); + when(checklistItemRepository.findAll()).thenReturn(List.of()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(OnboardingChecklistItem.class); + when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) + .thenAnswer(invocation -> { + OnboardingChecklistItem saved = invocation.getArgument(0); + saved.setId(11L); + saved.setVersion(0L); + return saved; + }); + + CompanyChecklistItemView result = service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest(" Submit badge ", " Bring photo ", "ADMIN", null, null, " key-1 ")); + + verify(checklistItemRepository).saveAndFlush(captor.capture()); + OnboardingChecklistItem saved = captor.getValue(); + assertThat(saved.getScopeType()).isEqualTo("COMPANY"); + assertThat(saved.getScopeId()).isEqualTo(COMPANY_ID); + assertThat(saved.getCompanyId()).isEqualTo(COMPANY_ID); + assertThat(saved.getItemKey()).startsWith("company-7-"); + assertThat(saved.getTitle()).isEqualTo("Submit badge"); + assertThat(saved.getDescription()).isEqualTo("Bring photo"); + assertThat(saved.getSortOrder()).isEqualTo(1000); + assertThat(saved.getRequired()).isFalse(); + assertThat(saved.getIdempotencyKey()).isEqualTo("key-1"); + assertThat(saved.getRequestHash()).hasSize(64).doesNotContain("Submit badge"); + assertThat(result.getItemId()).isEqualTo(11L); + assertThat(result.getVersion()).isZero(); + } + + @Test + void createCompanyChecklistItem_idempotentReplay_shouldReturnOriginalWithoutSecondSave() { + stubCompanyOwner(); + AtomicReference stored = new AtomicReference<>(); + when(checklistItemRepository.findAll()).thenAnswer(invocation -> stored.get() == null + ? List.of() : List.of(stored.get())); + when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) + .thenAnswer(invocation -> { + OnboardingChecklistItem saved = invocation.getArgument(0); + saved.setId(21L); + stored.set(saved); + return saved; + }); + CompanyChecklistItemCreateRequest request = + createRequest("Submit badge", "Details", "ADMIN", 200, false, "same-key"); + + CompanyChecklistItemView first = service.createCompanyChecklistItem(COMPANY_USER_ID, request); + CompanyChecklistItemView replay = service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest(" Submit badge ", " Details ", "ADMIN", 200, false, "same-key")); + + assertThat(first.getItemId()).isEqualTo(21L); + assertThat(replay.getItemId()).isEqualTo(21L); + assertThat(replay.getTitle()).isEqualTo("Submit badge"); + verify(checklistItemRepository).saveAndFlush(any(OnboardingChecklistItem.class)); + } + + @Test + void createCompanyChecklistItem_sameKeyDifferentBody_shouldReturnIdempotencyConflict() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 31L, COMPANY_ID, "existing", "ADMIN", 200, true, 0L, "same-key"); + existing.setRequestHash("a".repeat(64)); + when(checklistItemRepository.findAll()).thenReturn(List.of(existing)); + + assertThatThrownBy(() -> service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest("Different", "Details", "ADMIN", 200, false, "same-key"))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode()); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void createCompanyChecklistItem_atFiftyActiveItems_shouldReturnLimitExceeded() { + stubCompanyOwner(); + List existing = new ArrayList<>(); + for (int index = 0; index < 50; index++) { + existing.add(item((long) index + 1, COMPANY_ID, "item-" + index, + "OTHER", index, true, 0L, null)); + } + when(checklistItemRepository.findAll()).thenReturn(existing); + + assertThatThrownBy(() -> service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest("One more", null, "OTHER", 999, false, null))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode()); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void updateCompanyChecklistItem_withCurrentVersion_shouldApplyAllMutableFields() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 41L, COMPANY_ID, "update-me", "DOCUMENT", 100, true, 3L, null); + when(checklistItemRepository.findById(41L)).thenReturn(Optional.of(existing)); + when(checklistItemRepository.saveAndFlush(existing)).thenAnswer(invocation -> { + existing.setVersion(4L); + return existing; + }); + + CompanyChecklistItemView result = service.updateCompanyChecklistItem( + COMPANY_USER_ID, + 41L, + updateRequest("Updated", "New details", "TRAINING", 300, true, false, 3L)); + + assertThat(existing.getTitle()).isEqualTo("Updated"); + assertThat(existing.getDescription()).isEqualTo("New details"); + assertThat(existing.getCategory()).isEqualTo("TRAINING"); + assertThat(existing.getSortOrder()).isEqualTo(300); + assertThat(existing.getRequired()).isTrue(); + assertThat(existing.getActive()).isFalse(); + assertThat(result.getVersion()).isEqualTo(4L); + } + + @Test + void updateCompanyChecklistItem_withStaleVersion_shouldReturnConcurrentUpdate() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 42L, COMPANY_ID, "update-me", "DOCUMENT", 100, true, 3L, null); + when(checklistItemRepository.findById(42L)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.updateCompanyChecklistItem( + COMPANY_USER_ID, + 42L, + updateRequest("Updated", null, "DOCUMENT", 100, true, true, 2L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode()); + assertThat(existing.getTitle()).isEqualTo("update-me-title"); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void deleteCompanyChecklistItem_shouldSoftDeleteAndRepeatIdempotently() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 51L, COMPANY_ID, "delete-me", "OTHER", 100, true, 2L, null); + when(checklistItemRepository.findById(51L)).thenReturn(Optional.of(existing)); + when(checklistItemRepository.saveAndFlush(existing)).thenAnswer(invocation -> { + existing.setVersion(3L); + return existing; + }); + + CompanyChecklistItemView first = + service.deleteCompanyChecklistItem(COMPANY_USER_ID, 51L, 2L); + CompanyChecklistItemView repeat = + service.deleteCompanyChecklistItem(COMPANY_USER_ID, 51L, 2L); + + assertThat(first.getActive()).isFalse(); + assertThat(first.getVersion()).isEqualTo(3L); + assertThat(repeat.getActive()).isFalse(); + assertThat(repeat.getVersion()).isEqualTo(3L); + verify(checklistItemRepository).saveAndFlush(existing); + } + + @Test + void companyItemMutation_forOtherCompanyItem_shouldReturnNotFoundWithoutMutation() { + stubCompanyOwner(); + OnboardingChecklistItem other = item( + 61L, OTHER_COMPANY_ID, "other", "OTHER", 100, true, 1L, null); + when(checklistItemRepository.findById(61L)).thenReturn(Optional.of(other)); + + assertThatThrownBy(() -> service.deleteCompanyChecklistItem(COMPANY_USER_ID, 61L, 1L)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + assertThat(other.getActive()).isTrue(); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void companyOperation_withNonCompanyRole_shouldReturnForbidden() { + when(userRepository.findById(COMPANY_USER_ID)) + .thenReturn(Optional.of(User.builder() + .id(COMPANY_USER_ID) + .roles(Set.of(User.Role.STUDENT)) + .build())); + + assertThatThrownBy(() -> service.listCompanyChecklistItems(COMPANY_USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, checklistItemRepository); + } + + @Test + void companyOperation_withoutCompanyProfile_shouldReturnCompanyNotFound() { + when(userRepository.findById(COMPANY_USER_ID)) + .thenReturn(Optional.of(User.builder() + .id(COMPANY_USER_ID) + .roles(Set.of(User.Role.COMPANY)) + .build())); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.listCompanyChecklistItems(COMPANY_USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + verifyNoInteractions(checklistItemRepository); + } + + private void stubCompanyOwner() { + when(userRepository.findById(COMPANY_USER_ID)) + .thenReturn(Optional.of(User.builder() + .id(COMPANY_USER_ID) + .roles(Set.of(User.Role.COMPANY)) + .build())); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder() + .id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID) + .name("Example") + .build())); + } + + private OnboardingChecklistItem item( + Long id, + Long companyId, + String itemKey, + String category, + int sortOrder, + boolean active, + Long version, + String idempotencyKey) { + return OnboardingChecklistItem.builder() + .id(id) + .scopeType("COMPANY") + .scopeId(companyId) + .companyId(companyId) + .itemKey(itemKey) + .title(itemKey + "-title") + .description(itemKey + "-description") + .category(category) + .sortOrder(sortOrder) + .required(false) + .active(active) + .version(version) + .idempotencyKey(idempotencyKey) + .build(); + } + + private CompanyChecklistItemCreateRequest createRequest( + String title, + String description, + String category, + Integer sortOrder, + Boolean required, + String idempotencyKey) { + CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest(); + request.setTitle(title); + request.setDescription(description); + request.setCategory(category); + request.setSortOrder(sortOrder); + request.setRequired(required); + request.setIdempotencyKey(idempotencyKey); + return request; + } + + private CompanyChecklistItemUpdateRequest updateRequest( + String title, + String description, + String category, + Integer sortOrder, + Boolean required, + Boolean active, + Long version) { + CompanyChecklistItemUpdateRequest request = new CompanyChecklistItemUpdateRequest(); + request.setTitle(title); + request.setDescription(description); + request.setCategory(category); + request.setSortOrder(sortOrder); + request.setRequired(required); + request.setActive(active); + request.setVersion(version); + return request; + } +}