Browse Source

feat: add company onboarding checklist management

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
7370e510a8
  1. 3
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  2. 7
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  3. 53
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java
  4. 271
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java
  5. 34
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java
  6. 41
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java
  7. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java
  8. 277
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java
  9. 374
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java

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

@ -174,6 +174,9 @@ public enum ErrorCode {
// 入职清单 97xx // 入职清单 97xx
ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(9701, "onboarding checklist item not found"), 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"); ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently");
private final int code; private final int code;

7
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_INVALID_TRANSITION.getCode()
|| errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT; return HttpStatus.CONFLICT;
// 入职进度并发冲突 → 409 // 入职清单并发、上限与幂等冲突 → 409
if (errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) 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; return HttpStatus.CONFLICT;
// 举报限流 → 429 // 举报限流 → 429
if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode())

53
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.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; 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.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
@ -14,13 +17,19 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; 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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态") @Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态")
@RestController @RestController
@RequestMapping("/onboarding") @RequestMapping("/onboarding")
@ -53,6 +62,50 @@ public class OnboardingChecklistController {
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
} }
@Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本")
@GetMapping("/company/checklist-items")
public ResponseEntity<ApiResponse<List<CompanyChecklistItemView>>> listCompanyChecklistItems(
HttpServletRequest request) {
Long userId = resolveUserId();
List<CompanyChecklistItemView> data =
onboardingChecklistService.listCompanyChecklistItems(userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测")
@PostMapping("/company/checklist-items")
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> 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<ApiResponse<CompanyChecklistItemView>> 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<ApiResponse<CompanyChecklistItemView>> 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() { private Long resolveUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) { if (authentication == null || !authentication.isAuthenticated()) {

271
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.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.ChecklistItemView; 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.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
@ -15,12 +18,18 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.HexFormat;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -30,6 +39,13 @@ public class OnboardingChecklistService {
private static final String STANDARD_SOURCE = "STANDARD"; private static final String STANDARD_SOURCE = "STANDARD";
private static final String COMPANY_SOURCE = "COMPANY"; 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<String> ALLOWED_CATEGORIES = Set.of(
"DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER");
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
@ -101,6 +117,261 @@ public class OnboardingChecklistService {
return toProgressUpdateResponse(item, current, items, progressRows); return toProgressUpdateResponse(item, current, items, progressRows);
} }
@Transactional(readOnly = true)
public List<CompanyChecklistItemView> 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<OnboardingChecklistItem> 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<OnboardingChecklistItem> ownedCompanyItems(
List<OnboardingChecklistItem> 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) { private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId) User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

34
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;
}

41
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;
}

24
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;
}

277
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<String, Object> 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();
}
}

374
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<CompanyChecklistItemView> 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<CompanyChecklistItemCreateRequest> 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<OnboardingChecklistItem> 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<OnboardingChecklistItem> 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<OnboardingChecklistItem> 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;
}
}
Loading…
Cancel
Save