diff --git a/src/main/java/com/devpick/domain/community/dto/AnswerResponse.java b/src/main/java/com/devpick/domain/community/dto/AnswerResponse.java index eb294b5..4414468 100644 --- a/src/main/java/com/devpick/domain/community/dto/AnswerResponse.java +++ b/src/main/java/com/devpick/domain/community/dto/AnswerResponse.java @@ -13,6 +13,7 @@ public record AnswerResponse( UUID postId, String content, Boolean isAdopted, + Boolean isEdited, UUID authorId, String authorNickname, Job authorJob, @@ -26,6 +27,7 @@ public static AnswerResponse of(Answer answer) { answer.getPost().getId(), answer.getContent(), answer.getIsAdopted(), + answer.getIsEdited(), answer.getUser().getId(), answer.getUser().getNickname(), answer.getUser().getJob(), diff --git a/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java b/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java index 17c51f6..52dbce4 100644 --- a/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java +++ b/src/main/java/com/devpick/domain/community/dto/AnswerWithCommentsResponse.java @@ -19,6 +19,7 @@ public record AnswerWithCommentsResponse( Level authorLevel, String authorProfileImage, Boolean isAdopted, + Boolean isEdited, Instant createdAt, Instant updatedAt, List comments @@ -33,6 +34,7 @@ public static AnswerWithCommentsResponse of(Answer answer, List comment answer.getUser().getLevel(), answer.getUser().getProfileImage(), answer.getIsAdopted(), + answer.getIsEdited(), answer.getCreatedAt() != null ? answer.getCreatedAt().toInstant(ZoneOffset.UTC) : null, answer.getUpdatedAt() != null ? answer.getUpdatedAt().toInstant(ZoneOffset.UTC) : null, comments.stream().map(CommentResponse::of).toList() diff --git a/src/main/java/com/devpick/domain/community/entity/Answer.java b/src/main/java/com/devpick/domain/community/entity/Answer.java index f035e51..2c69eaa 100644 --- a/src/main/java/com/devpick/domain/community/entity/Answer.java +++ b/src/main/java/com/devpick/domain/community/entity/Answer.java @@ -32,8 +32,13 @@ public class Answer extends BaseTimeEntity { @Builder.Default private Boolean isAdopted = false; + @Column(name = "is_edited", nullable = false, columnDefinition = "boolean default false") + @Builder.Default + private Boolean isEdited = false; + public void update(String content) { this.content = content; + this.isEdited = true; } public void adopt() { diff --git a/src/main/java/com/devpick/domain/job/dto/JobApiModels.java b/src/main/java/com/devpick/domain/job/dto/JobApiModels.java index c68cad7..a5eebf8 100644 --- a/src/main/java/com/devpick/domain/job/dto/JobApiModels.java +++ b/src/main/java/com/devpick/domain/job/dto/JobApiModels.java @@ -47,7 +47,8 @@ public record JobListItemResponse( List matchedTags, List missingTags, boolean bookmarked, - String status + String status, + boolean resumeAvailable ) {} public record MatchItemResponse(String label, String status) {} @@ -81,6 +82,7 @@ public record JobDetailResponse( List missingTags, boolean bookmarked, String status, + boolean resumeAvailable, String salary, String applyUrl, List responsibilities, diff --git a/src/main/java/com/devpick/domain/job/service/JobService.java b/src/main/java/com/devpick/domain/job/service/JobService.java index 4de1ff9..6f12695 100644 --- a/src/main/java/com/devpick/domain/job/service/JobService.java +++ b/src/main/java/com/devpick/domain/job/service/JobService.java @@ -286,6 +286,7 @@ public JobDetailResponse getJobDetail(UUID userId, UUID jobId) { base.missingTags(), base.bookmarked(), base.status(), + base.resumeAvailable(), p.getSalaryDisplay() != null ? p.getSalaryDisplay() : "", p.getApplyUrl() != null ? p.getApplyUrl() : p.getSourceUrl(), new ArrayList<>(p.getResponsibilities()), @@ -542,7 +543,8 @@ private JobListItemResponse toListItem(JobPosting p, Map userSk m.matchedTags(), m.missingTags(), bookmarked, - p.getStatus().name() + p.getStatus().name(), + !userSkills.isEmpty() ); } diff --git a/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java b/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java index 8b5d761..9c0826f 100644 --- a/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java +++ b/src/test/java/com/devpick/domain/community/controller/AnswerControllerTest.java @@ -81,7 +81,7 @@ void setUp() { ); answerResponse = new AnswerResponse( - answerId, postId, "Test Answer", false, + answerId, postId, "Test Answer", false, false, userId, "tester", null, null, Instant.now(), Instant.now() ); @@ -125,7 +125,7 @@ void createAnswer_postNotFound_returns404() throws Exception { void updateAnswer_success_returns200() throws Exception { AnswerUpdateRequest request = new AnswerUpdateRequest("Updated Answer"); AnswerResponse updated = new AnswerResponse( - answerId, postId, "Updated Answer", false, + answerId, postId, "Updated Answer", false, true, userId, "tester", null, null, Instant.now(), Instant.now()); given(answerService.updateAnswer(eq(userId), eq(postId), eq(answerId), any())).willReturn(updated); @@ -164,7 +164,7 @@ void deleteAnswer_success_returns204() throws Exception { @DisplayName("POST /posts/{postId}/answers/{answerId}/adopt - 채택 성공 시 200 반환") void adoptAnswer_success_returns200() throws Exception { AnswerResponse adopted = new AnswerResponse( - answerId, postId, "Test Answer", true, + answerId, postId, "Test Answer", true, false, userId, "tester", null, null, Instant.now(), Instant.now()); given(answerService.adoptAnswer(userId, postId, answerId)).willReturn(adopted); @@ -190,7 +190,7 @@ void adoptAnswer_alreadyAdopted_returns409() throws Exception { void getAnswers_success_returns200() throws Exception { AnswerWithCommentsResponse answerWithComments = new AnswerWithCommentsResponse( answerId, "Test Answer", userId, "tester", null, null, null, - false, Instant.now(), Instant.now(), List.of() + false, false, Instant.now(), Instant.now(), List.of() ); AnswerListResponse listResponse = new AnswerListResponse(List.of(answerWithComments)); given(answerService.getAnswers(postId)).willReturn(listResponse); diff --git a/src/test/java/com/devpick/domain/community/entity/AnswerTest.java b/src/test/java/com/devpick/domain/community/entity/AnswerTest.java index 42280b8..62ff7de 100644 --- a/src/test/java/com/devpick/domain/community/entity/AnswerTest.java +++ b/src/test/java/com/devpick/domain/community/entity/AnswerTest.java @@ -11,6 +11,41 @@ @DisplayName("Answer Entity 단위 테스트") class AnswerTest { + @Test + @DisplayName("빌더 기본값 - isEdited=false") + void builder_defaultIsEditedFalse() { + User user = buildUser(); + Post post = buildPost(user); + Answer answer = Answer.builder().post(post).user(user).content("내용").build(); + assertThat(answer.getIsEdited()).isFalse(); + } + + @Test + @DisplayName("update() 호출 시 isEdited=true, 내용이 변경된다") + void update_setsIsEditedTrueAndUpdatesContent() { + User user = buildUser(); + Post post = buildPost(user); + Answer answer = Answer.builder().post(post).user(user).content("원본").build(); + + answer.update("수정된 내용"); + + assertThat(answer.getIsEdited()).isTrue(); + assertThat(answer.getContent()).isEqualTo("수정된 내용"); + } + + @Test + @DisplayName("adopt() 호출 시 isEdited는 변경되지 않는다") + void adopt_doesNotChangeIsEdited() { + User user = buildUser(); + Post post = buildPost(user); + Answer answer = Answer.builder().post(post).user(user).content("내용").build(); + + answer.adopt(); + + assertThat(answer.getIsEdited()).isFalse(); + assertThat(answer.getIsAdopted()).isTrue(); + } + @Test @DisplayName("빌더 기본값 - isAdopted=false") void builder_defaultIsAdoptedFalse() { diff --git a/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java b/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java index 6c94ceb..eeb7576 100644 --- a/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java +++ b/src/test/java/com/devpick/domain/community/service/AnswerServiceTest.java @@ -157,7 +157,7 @@ void createAnswer_postNotFound_throwsException() { } @Test - @DisplayName("updateAnswer — 성공 시 수정된 답변 반환") + @DisplayName("updateAnswer — 성공 시 수정된 답변 반환, isEdited=true") void updateAnswer_success_returnsUpdatedAnswer() { AnswerUpdateRequest request = new AnswerUpdateRequest("Updated Answer"); given(answerRepository.findById(answerId)).willReturn(Optional.of(answer)); @@ -165,6 +165,16 @@ void updateAnswer_success_returnsUpdatedAnswer() { AnswerResponse response = answerService.updateAnswer(userId, postId, answerId, request); assertThat(response.content()).isEqualTo("Updated Answer"); + assertThat(response.isEdited()).isTrue(); + } + + @Test + @DisplayName("updateAnswer — 수정 전 isEdited=false, 수정 후 isEdited=true") + void updateAnswer_isEdited_toggledByUpdate() { + given(answerRepository.findById(answerId)).willReturn(Optional.of(answer)); + + AnswerResponse before = answerService.updateAnswer(userId, postId, answerId, new AnswerUpdateRequest("내용1")); + assertThat(before.isEdited()).isTrue(); } @Test @@ -219,7 +229,7 @@ void deleteAnswer_unauthorized_throwsException() { } @Test - @DisplayName("adoptAnswer — 성공 시 채택된 답변 반환") + @DisplayName("adoptAnswer — 성공 시 isAdopted=true, isEdited는 변경되지 않는다") void adoptAnswer_success_adoptsAnswer() { given(postRepository.findById(postId)).willReturn(Optional.of(post)); given(answerRepository.findAdoptedByPostIdForUpdate(postId)).willReturn(List.of()); @@ -228,6 +238,7 @@ void adoptAnswer_success_adoptsAnswer() { AnswerResponse response = answerService.adoptAnswer(userId, postId, answerId); assertThat(response.isAdopted()).isTrue(); + assertThat(response.isEdited()).isFalse(); } @Test diff --git a/src/test/java/com/devpick/domain/job/service/JobServiceTest.java b/src/test/java/com/devpick/domain/job/service/JobServiceTest.java index a8102b9..dea48ca 100644 --- a/src/test/java/com/devpick/domain/job/service/JobServiceTest.java +++ b/src/test/java/com/devpick/domain/job/service/JobServiceTest.java @@ -1,12 +1,20 @@ package com.devpick.domain.job.service; +import com.devpick.domain.job.dto.JobApiModels.JobDetailResponse; +import com.devpick.domain.job.entity.EmploymentType; +import com.devpick.domain.job.entity.JobParseStatus; import com.devpick.domain.job.entity.JobPosting; +import com.devpick.domain.job.entity.JobPostingCategory; +import com.devpick.domain.job.entity.JobPostingStatus; +import com.devpick.domain.job.entity.PostingExperienceLevel; import com.devpick.domain.job.repository.JobBookmarkRepository; import com.devpick.domain.job.repository.JobPostingRepository; import com.devpick.domain.point.entity.PointAction; import com.devpick.domain.point.service.PointService; import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.user.entity.Tag; import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.entity.UserTag; import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; @@ -18,9 +26,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; @@ -100,7 +110,76 @@ void bookmark_userNotFound_throwsException() { assertThatThrownBy(() -> jobService.bookmark(userId, jobId)) .isInstanceOf(DevpickException.class) - .satisfies(e -> org.assertj.core.api.Assertions.assertThat( + .satisfies(e -> assertThat( ((DevpickException) e).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); } + + private static final UUID INTERNAL_OPS_USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + private JobPosting mockListableJob(UUID jobId) { + JobPosting job = mock(JobPosting.class); + given(job.getId()).willReturn(jobId); + given(job.getTitle()).willReturn("백엔드 개발자"); + given(job.getCompanyName()).willReturn("카카오"); + given(job.getTechStack()).willReturn(List.of()); + given(job.getRequiredSkills()).willReturn(List.of()); + given(job.getPreferredSkills()).willReturn(List.of()); + given(job.getCompanyLogoUrl()).willReturn(null); + given(job.getLocation()).willReturn("서울"); + given(job.getRollingDeadline()).willReturn(false); + given(job.getDeadline()).willReturn(null); + given(job.getEmploymentType()).willReturn(EmploymentType.FULL_TIME); + given(job.getJobCategory()).willReturn(JobPostingCategory.BACKEND); + given(job.getExperienceLevel()).willReturn(PostingExperienceLevel.JUNIOR); + given(job.getStatus()).willReturn(JobPostingStatus.ACTIVE); + given(job.getParseStatus()).willReturn(JobParseStatus.OK); + given(job.getSalaryDisplay()).willReturn(""); + given(job.getApplyUrl()).willReturn("https://example.com"); + given(job.getResponsibilities()).willReturn(List.of()); + given(job.getRequirementBullets()).willReturn(List.of()); + given(job.getPreferredQualificationBullets()).willReturn(List.of()); + given(job.getBenefits()).willReturn(List.of()); + given(job.getHiringProcess()).willReturn(List.of()); + given(job.getJdImageUrls()).willReturn(List.of()); + return job; + } + + @Test + @DisplayName("getJobDetail — 이력서·태그 없는 유저는 resumeAvailable=false") + void getJobDetail_noSkills_resumeAvailableFalse() { + UUID jobId = UUID.randomUUID(); + JobPosting job = mockListableJob(jobId); + + given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job)); + given(masterResumeRepository.findByUserId(INTERNAL_OPS_USER_ID)).willReturn(Optional.empty()); + given(userRepository.findById(INTERNAL_OPS_USER_ID)).willReturn(Optional.empty()); + given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(INTERNAL_OPS_USER_ID, jobId)).willReturn(false); + + JobDetailResponse response = jobService.getJobDetail(INTERNAL_OPS_USER_ID, jobId); + + assertThat(response.resumeAvailable()).isFalse(); + } + + @Test + @DisplayName("getJobDetail — 기술 태그가 등록된 유저는 resumeAvailable=true") + void getJobDetail_hasTagSkills_resumeAvailableTrue() { + UUID jobId = UUID.randomUUID(); + JobPosting job = mockListableJob(jobId); + + Tag tag = mock(Tag.class); + given(tag.getName()).willReturn("Java"); + UserTag userTag = mock(UserTag.class); + given(userTag.getTag()).willReturn(tag); + User userWithTags = mock(User.class); + given(userWithTags.getUserTags()).willReturn(List.of(userTag)); + + given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job)); + given(masterResumeRepository.findByUserId(INTERNAL_OPS_USER_ID)).willReturn(Optional.empty()); + given(userRepository.findById(INTERNAL_OPS_USER_ID)).willReturn(Optional.of(userWithTags)); + given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(INTERNAL_OPS_USER_ID, jobId)).willReturn(false); + + JobDetailResponse response = jobService.getJobDetail(INTERNAL_OPS_USER_ID, jobId); + + assertThat(response.resumeAvailable()).isTrue(); + } }