From 10050290c19f33d50fc0dff033f846eb53be5ce0 Mon Sep 17 00:00:00 2001 From: juhyun Date: Sat, 21 Feb 2026 16:53:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=EC=95=BD=EC=86=8D=ED=9A=8C?= =?UTF-8?q?=EA=B3=A0=20=EC=83=9D=EC=84=B1=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dokdok/meeting/entity/Meeting.java | 16 ++++++ .../api/RetrospectiveSummaryApi.java | 49 +++++++++++++++++++ .../RetrospectiveSummaryController.java | 10 ++++ .../RetrospectiveSummaryResponse.java | 14 +++++- .../exception/RetrospectiveErrorCode.java | 4 +- .../service/MeetingRetrospectiveService.java | 18 ++++++- .../service/RetrospectiveSummaryService.java | 29 ++++++++++- .../RetrospectiveSummaryServiceTest.java | 4 ++ 8 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/dokdok/meeting/entity/Meeting.java b/src/main/java/com/dokdok/meeting/entity/Meeting.java index 7e7b926e..7c8d4eb5 100644 --- a/src/main/java/com/dokdok/meeting/entity/Meeting.java +++ b/src/main/java/com/dokdok/meeting/entity/Meeting.java @@ -64,6 +64,13 @@ public class Meeting extends BaseTimeEntity { @Column(name = "meeting_end_date") private LocalDateTime meetingEndDate; + @Column(name = "retrospective_published") + @Builder.Default + private Boolean retrospectivePublished = false; + + @Column(name = "retrospective_published_at") + private LocalDateTime retrospectivePublishedAt; + public static Meeting create(MeetingCreateRequest request, Gathering gathering, Book book, User user, Integer maxParticipants) { String meetingName = request.meetingName(); @@ -143,4 +150,13 @@ public String getFormattedTime() { return meetingStartDate.format(formatter) + "-" + meetingEndDate.format(formatter); } + public void publishRetrospective() { + this.retrospectivePublished = true; + this.retrospectivePublishedAt = LocalDateTime.now(); + } + + public boolean isRetrospectivePublished() { + return Boolean.TRUE.equals(this.retrospectivePublished); + } + } diff --git a/src/main/java/com/dokdok/retrospective/api/RetrospectiveSummaryApi.java b/src/main/java/com/dokdok/retrospective/api/RetrospectiveSummaryApi.java index 5f1eb9b9..96a80c8e 100644 --- a/src/main/java/com/dokdok/retrospective/api/RetrospectiveSummaryApi.java +++ b/src/main/java/com/dokdok/retrospective/api/RetrospectiveSummaryApi.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "AI 요약", description = "AI 요약 관련 API") @@ -183,4 +184,52 @@ ResponseEntity> updateRetrospectiveSum @PathVariable Long meetingId, @Valid @RequestBody RetrospectiveSummaryUpdateRequest request ); + + @Operation( + summary = "약속 회고 생성 (developer: 오주현)", + description = """ + 약속 회고를 생성(퍼블리시)합니다. + - 권한: 모임장, 약속장 + - 제약: 모임장 또는 약속장만 생성 가능 + - 생성 후 모든 약속 참여자가 공동 회고를 조회할 수 있습니다. + """, + parameters = { + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) + } + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "약속 회고 생성 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = RetrospectiveSummaryResponse.class), + examples = @ExampleObject(value = """ + { + "code": "CREATED", + "message": "약속 회고 생성 성공", + "data": { + "meetingId": 1, + "isPublished": true, + "publishedAt": "2026-02-21T15:00:00", + "topics": [...] + } + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "접근 권한 없음", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + {"code": "R105", "message": "회고에 접근할 권한이 없습니다.", "data": null} + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 생성됨", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + {"code": "R108", "message": "이미 약속 회고가 생성되었습니다.", "data": null} + """))) + }) + @PostMapping(value = "/publish", produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> publishRetrospective( + @PathVariable Long meetingId + ); } \ No newline at end of file diff --git a/src/main/java/com/dokdok/retrospective/controller/RetrospectiveSummaryController.java b/src/main/java/com/dokdok/retrospective/controller/RetrospectiveSummaryController.java index 68d6deb7..dec1232e 100644 --- a/src/main/java/com/dokdok/retrospective/controller/RetrospectiveSummaryController.java +++ b/src/main/java/com/dokdok/retrospective/controller/RetrospectiveSummaryController.java @@ -38,4 +38,14 @@ public ResponseEntity> updateRetrospec return ApiResponse.success(response, "AI 요약 수정 성공"); } + + @Override + @PostMapping("/publish") + public ResponseEntity> publishRetrospective( + @PathVariable Long meetingId + ) { + RetrospectiveSummaryResponse response = retrospectiveSummaryService.publishRetrospective(meetingId); + + return ApiResponse.created(response, "약속 회고 생성 성공"); + } } diff --git a/src/main/java/com/dokdok/retrospective/dto/response/RetrospectiveSummaryResponse.java b/src/main/java/com/dokdok/retrospective/dto/response/RetrospectiveSummaryResponse.java index 3ffd69f0..29186a0b 100644 --- a/src/main/java/com/dokdok/retrospective/dto/response/RetrospectiveSummaryResponse.java +++ b/src/main/java/com/dokdok/retrospective/dto/response/RetrospectiveSummaryResponse.java @@ -1,10 +1,12 @@ package com.dokdok.retrospective.dto.response; +import com.dokdok.meeting.entity.Meeting; import com.dokdok.retrospective.entity.TopicRetrospectiveSummary; import com.dokdok.topic.entity.Topic; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; +import java.time.LocalDateTime; import java.util.List; @Schema(description = "AI 요약 조회 응답") @@ -13,12 +15,20 @@ public record RetrospectiveSummaryResponse( @Schema(description = "약속 ID", example = "1") Long meetingId, + @Schema(description = "약속 회고 생성 여부", example = "false") + Boolean isPublished, + + @Schema(description = "약속 회고 생성 일시", example = "2026-02-21T15:00:00") + LocalDateTime publishedAt, + @Schema(description = "토픽 목록") List topics ) { - public static RetrospectiveSummaryResponse from(Long meetingId, List topics) { + public static RetrospectiveSummaryResponse from(Meeting meeting, List topics) { return RetrospectiveSummaryResponse.builder() - .meetingId(meetingId) + .meetingId(meeting.getId()) + .isPublished(meeting.isRetrospectivePublished()) + .publishedAt(meeting.getRetrospectivePublishedAt()) .topics(topics) .build(); } diff --git a/src/main/java/com/dokdok/retrospective/exception/RetrospectiveErrorCode.java b/src/main/java/com/dokdok/retrospective/exception/RetrospectiveErrorCode.java index f99aca76..8c17f8e7 100644 --- a/src/main/java/com/dokdok/retrospective/exception/RetrospectiveErrorCode.java +++ b/src/main/java/com/dokdok/retrospective/exception/RetrospectiveErrorCode.java @@ -15,7 +15,9 @@ public enum RetrospectiveErrorCode implements BaseErrorCode { RETROSPECTIVE_ALREADY_DELETED("R104", "이미 삭제된 개인 회고입니다.", HttpStatus.NOT_FOUND), NO_ACCESS_RETROSPECTIVE("R105", "회고에 접근할 권한이 없습니다.", HttpStatus.FORBIDDEN), SUMMARY_NOT_FOUND("R106", "AI 요약을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - NOT_AUTHOR_OF_RETROSPECTIVE("R107", "사용자가 작성한 회고가 아닙니다.", HttpStatus.FORBIDDEN); + NOT_AUTHOR_OF_RETROSPECTIVE("R107", "사용자가 작성한 회고가 아닙니다.", HttpStatus.FORBIDDEN), + RETROSPECTIVE_ALREADY_PUBLISHED("R108", "이미 약속 회고가 생성되었습니다.", HttpStatus.CONFLICT), + RETROSPECTIVE_NOT_PUBLISHED("R109", "약속 회고가 아직 생성되지 않았습니다.", HttpStatus.FORBIDDEN); private final String code; diff --git a/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java b/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java index e0ad51cd..54945cca 100644 --- a/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java +++ b/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java @@ -11,6 +11,7 @@ import com.dokdok.retrospective.exception.RetrospectiveErrorCode; import com.dokdok.retrospective.exception.RetrospectiveException; import com.dokdok.retrospective.repository.RetrospectiveRepository; +import com.dokdok.gathering.entity.GatheringRole; import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository; import com.dokdok.storage.service.StorageService; import com.dokdok.topic.entity.Topic; @@ -51,7 +52,12 @@ public MeetingRetrospectiveResponse getMeetingRetrospective(Long meetingId){ Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId); - // 권한 검증 + // 퍼블리시 여부 확인 (퍼블리시 후에만 접근 가능) + if (!meeting.isRetrospectivePublished()) { + throw new RetrospectiveException(RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED); + } + + // 권한 검증 (약속 참여자 또는 모임장) retrospectiveValidator.validateMeetingRetrospectiveAccess( meeting.getGathering().getId(), meetingId, @@ -85,6 +91,11 @@ public CursorResponse topics = topicRepository.findByMeetingIdAndTopicStatusOrderByConfirmOrderAsc( @@ -61,7 +62,7 @@ public RetrospectiveSummaryResponse getRetrospectiveSummary(Long meetingId) { )) .toList(); - return RetrospectiveSummaryResponse.from(meetingId, topicResponses); + return RetrospectiveSummaryResponse.from(meeting, topicResponses); } @Transactional @@ -97,4 +98,28 @@ public RetrospectiveSummaryResponse updateRetrospectiveSummary( // 수정된 결과 반환 return getRetrospectiveSummary(meetingId); } + + @Transactional + public RetrospectiveSummaryResponse publishRetrospective(Long meetingId) { + Long userId = SecurityUtil.getCurrentUserId(); + + Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId); + + // 권한 검증 (모임장/약속장만 생성 가능) + retrospectiveValidator.validateSummaryUpdatePermission( + meeting.getGathering().getId(), + meetingId, + userId + ); + + // 이미 생성된 경우 예외 + if (meeting.isRetrospectivePublished()) { + throw new RetrospectiveException(RetrospectiveErrorCode.RETROSPECTIVE_ALREADY_PUBLISHED); + } + + // 약속 회고 생성 (퍼블리시) + meeting.publishRetrospective(); + + return getRetrospectiveSummary(meetingId); + } } diff --git a/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java b/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java index 4d26d1c4..01826797 100644 --- a/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java +++ b/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java @@ -7,12 +7,15 @@ import com.dokdok.retrospective.dto.request.RetrospectiveSummaryUpdateRequest; import com.dokdok.retrospective.dto.response.RetrospectiveSummaryResponse; import com.dokdok.retrospective.entity.TopicRetrospectiveSummary; +import com.dokdok.retrospective.exception.RetrospectiveErrorCode; +import com.dokdok.retrospective.exception.RetrospectiveException; import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository; import com.dokdok.topic.entity.Topic; import com.dokdok.topic.entity.TopicStatus; import com.dokdok.topic.repository.TopicRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -24,6 +27,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; From 363cffee240a1c9a71c5faaee9659d108d0baf52 Mon Sep 17 00:00:00 2001 From: juhyun Date: Sat, 21 Feb 2026 17:05:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat=20:=20=EC=95=BD=EC=86=8D=ED=9A=8C?= =?UTF-8?q?=EA=B3=A0=20=EC=83=9D=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingRetrospectiveServiceTest.java | 83 ++++++++++++++++++- .../RetrospectiveSummaryServiceTest.java | 54 +++++++++++- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java b/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java index c1331ff6..ef262cff 100644 --- a/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java +++ b/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java @@ -97,7 +97,11 @@ void getMeetingRetrospective_throwsWhenNotGatheringMember() { Long gatheringId = 1L; Gathering gathering = Gathering.builder().id(gatheringId).build(); - Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build(); + Meeting meeting = Meeting.builder() + .id(meetingId) + .gathering(gathering) + .retrospectivePublished(true) + .build(); try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); @@ -120,7 +124,11 @@ void getMeetingRetrospective_throwsWhenNotMeetingMember() { Long gatheringId = 1L; Gathering gathering = Gathering.builder().id(gatheringId).build(); - Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build(); + Meeting meeting = Meeting.builder() + .id(meetingId) + .gathering(gathering) + .retrospectivePublished(true) + .build(); try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); @@ -135,6 +143,31 @@ void getMeetingRetrospective_throwsWhenNotMeetingMember() { } } + @Test + @DisplayName("퍼블리시되지 않은 약속 회고 조회 시 예외가 발생한다") + void getMeetingRetrospective_throwsWhenNotPublished() { + Long meetingId = 1L; + Long userId = 1L; + Long gatheringId = 1L; + + Gathering gathering = Gathering.builder().id(gatheringId).build(); + Meeting meeting = Meeting.builder() + .id(meetingId) + .gathering(gathering) + .retrospectivePublished(false) + .build(); + + try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + + when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting); + + assertThatThrownBy(() -> meetingRetrospectiveService.getMeetingRetrospective(meetingId)) + .isInstanceOf(RetrospectiveException.class) + .hasFieldOrPropertyWithValue("errorCode", RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED); + } + } + @Test @DisplayName("코멘트가 없어도 정상적으로 조회한다") void getMeetingRetrospective_withNoComments_success() { @@ -151,6 +184,7 @@ void getMeetingRetrospective_withNoComments_success() { .meetingName("모임") .meetingStartDate(LocalDateTime.of(2026, 1, 15, 19, 0)) .meetingEndDate(LocalDateTime.of(2026, 1, 15, 21, 0)) + .retrospectivePublished(true) .build(); Topic topic = Topic.builder().id(1L).meeting(meeting).title("토픽1").build(); @@ -182,6 +216,39 @@ void getMeetingRetrospective_withNoComments_success() { } } + @Test + @DisplayName("퍼블리시되지 않은 약속에 코멘트 작성 시 예외가 발생한다") + void createMeetingRetrospective_throwsWhenNotPublished() { + Long meetingId = 1L; + Long userId = 1L; + Long gatheringId = 1L; + + Gathering gathering = Gathering.builder().id(gatheringId).build(); + Meeting meeting = Meeting.builder() + .id(meetingId) + .gathering(gathering) + .retrospectivePublished(false) + .build(); + User user = User.builder().id(userId).build(); + MeetingRetrospectiveRequest request = new MeetingRetrospectiveRequest("코멘트"); + + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + when(customOAuth2User.getUser()).thenReturn(user); + + try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + securityUtilMock.when(SecurityUtil::getCurrentUser).thenReturn(customOAuth2User); + + when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting); + + assertThatThrownBy(() -> meetingRetrospectiveService.createMeetingRetrospective(meetingId, request)) + .isInstanceOf(RetrospectiveException.class) + .hasFieldOrPropertyWithValue("errorCode", RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED); + + verify(retrospectiveRepository, never()).save(any()); + } + } + @Test @DisplayName("공동 회고를 정상적으로 작성한다") void createMeetingRetrospective_success() { @@ -191,7 +258,11 @@ void createMeetingRetrospective_success() { Long gatheringId = 1L; Gathering gathering = Gathering.builder().id(gatheringId).build(); - Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build(); + Meeting meeting = Meeting.builder() + .id(meetingId) + .gathering(gathering) + .retrospectivePublished(true) + .build(); User user = User.builder().id(userId).nickname("사용자1").profileImageUrl("https://image.jpg").build(); MeetingRetrospectiveRequest request = new MeetingRetrospectiveRequest("회고 코멘트입니다."); @@ -264,7 +335,11 @@ void createMeetingRetrospective_throwsWhenNoAccess() { Long gatheringId = 1L; Gathering gathering = Gathering.builder().id(gatheringId).build(); - Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build(); + Meeting meeting = Meeting.builder() + .id(meetingId) + .gathering(gathering) + .retrospectivePublished(true) + .build(); User user = User.builder().id(userId).build(); MeetingRetrospectiveRequest request = new MeetingRetrospectiveRequest( "코멘트"); diff --git a/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java b/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java index 01826797..c73552c4 100644 --- a/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java +++ b/src/test/java/com/dokdok/retrospective/service/RetrospectiveSummaryServiceTest.java @@ -30,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -92,7 +93,7 @@ void getRetrospectiveSummary_returnsMappedSummary() { when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting); doNothing().when(retrospectiveValidator) - .validateMeetingRetrospectiveAccess(gatheringId, meetingId, userId); + .validateSummaryUpdatePermission(gatheringId, meetingId, userId); when(topicRepository.findByMeetingIdAndTopicStatusOrderByConfirmOrderAsc(meetingId, TopicStatus.CONFIRMED)) .thenReturn(List.of(topic)); when(topicRetrospectiveSummaryRepository.findAllByTopicIdIn(List.of(topic.getId()))) @@ -101,6 +102,7 @@ void getRetrospectiveSummary_returnsMappedSummary() { RetrospectiveSummaryResponse response = retrospectiveSummaryService.getRetrospectiveSummary(meetingId); assertThat(response.meetingId()).isEqualTo(meetingId); + assertThat(response.isPublished()).isFalse(); assertThat(response.topics()).hasSize(1); assertThat(response.topics().get(0).topicId()).isEqualTo(topic.getId()); assertThat(response.topics().get(0).summary()).isEqualTo("기존 요약"); @@ -145,7 +147,55 @@ void updateRetrospectiveSummary_updatesSummaryAndReturns() { assertThat(response.topics().get(0).summary()).isEqualTo("수정 요약"); assertThat(response.topics().get(0).keyPoints()).hasSize(1); assertThat(response.topics().get(0).keyPoints().get(0).title()).isEqualTo("수정 포인트"); - verify(retrospectiveValidator).validateSummaryUpdatePermission(gatheringId, meetingId, userId); + // update에서 1번, 내부 getRetrospectiveSummary에서 1번 = 총 2번 호출 + verify(retrospectiveValidator, times(2)).validateSummaryUpdatePermission(gatheringId, meetingId, userId); + } + } + + @Nested + @DisplayName("약속 회고 생성(퍼블리시)") + class PublishRetrospectiveTest { + + @Test + @DisplayName("약속 회고 생성 시 isPublished가 true로 변경된다") + void publishRetrospective_success() { + try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + + when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting); + doNothing().when(retrospectiveValidator) + .validateSummaryUpdatePermission(gatheringId, meetingId, userId); + when(topicRepository.findByMeetingIdAndTopicStatusOrderByConfirmOrderAsc(meetingId, TopicStatus.CONFIRMED)) + .thenReturn(List.of(topic)); + when(topicRetrospectiveSummaryRepository.findAllByTopicIdIn(List.of(topic.getId()))) + .thenReturn(List.of(summary)); + + RetrospectiveSummaryResponse response = retrospectiveSummaryService.publishRetrospective(meetingId); + + assertThat(meeting.isRetrospectivePublished()).isTrue(); + assertThat(meeting.getRetrospectivePublishedAt()).isNotNull(); + assertThat(response.isPublished()).isTrue(); + assertThat(response.publishedAt()).isNotNull(); + } + } + + @Test + @DisplayName("이미 생성된 약속 회고를 다시 생성하려고 하면 예외가 발생한다") + void publishRetrospective_alreadyPublished_throwsException() { + // 이미 퍼블리시된 상태로 설정 + meeting.publishRetrospective(); + + try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + + when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting); + doNothing().when(retrospectiveValidator) + .validateSummaryUpdatePermission(gatheringId, meetingId, userId); + + assertThatThrownBy(() -> retrospectiveSummaryService.publishRetrospective(meetingId)) + .isInstanceOf(RetrospectiveException.class) + .hasFieldOrPropertyWithValue("errorCode", RetrospectiveErrorCode.RETROSPECTIVE_ALREADY_PUBLISHED); + } } } }