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