9 changed files with 1083 additions and 3 deletions
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue