Repository: CAUCSE/CAUSW_backend
Pull request #806: feat: 캘린더 삭제 api 구현
Author: login: bingle625
Created: 2025-03-15T13:54:17Z | Merged: 2025-03-17T04:13:28Z

### 🚩 관련사항
- 2025년 3월 14일 디스코드 backend 채널을 통해 요청받은 캘린더 삭제 API 구현 건입니다.


### 📢 전달사항
- 캘린더 수정 API 를 참고하여 response와 validation을 작성했습니다.


### 📸 스크린샷
<img width="327" alt="image" src="https://github.com/user-attachments/assets/849f1a06-b1c1-40e9-9da0-ff561c8acd47" />

<img width="1422" alt="image" src="https://github.com/user-attachments/assets/e1b2f613-07d0-4934-ba33-d978f13b3dce" />


### 📃 진행사항
- [x] 캘린더 삭제 API 구현 완료
- [x] 캘린더 삭제 
- [x] 테스트 코드 작성


### ⚙️ 기타사항
- batch delete API 를 구현해야 할까 고려하였으나, 
관련 정책 사항을 정한 바가 없으므로 단일 삭제 API를 구현하였습니다.

개발기간: 4H


Diff:
diff --git a/src/main/java/net/causw/adapter/web/CalendarController.java b/src/main/java/net/causw/adapter/web/CalendarController.java
index 8db6bd816..3be35fe24 100644
--- a/src/main/java/net/causw/adapter/web/CalendarController.java
+++ b/src/main/java/net/causw/adapter/web/CalendarController.java
@@ -30,7 +30,7 @@ public class CalendarController {
     @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
             "@securityService.isAdminOrPresidentOrVicePresident()")
     @ApiResponses({
-            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
+            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = CalendarsResponseDto.class))),
             @ApiResponse(responseCode = "4000", description = "캘린더를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4000", description = "로그인된 사용자를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4012", description = "접근 권한이 없습니다. 다시 로그인 해주세요. 문제 반복시 관리자에게 문의해주세요.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
@@ -45,7 +45,7 @@ public CalendarsResponseDto findCalendars(@RequestParam("year") Integer year) {
     @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
             "@securityService.isAdminOrPresidentOrVicePresident()")
     @ApiResponses({
-            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
+            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = CalendarResponseDto.class))),
             @ApiResponse(responseCode = "4000", description = "캘린더를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4000", description = "로그인된 사용자를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4012", description = "접근 권한이 없습니다. 다시 로그인 해주세요. 문제 반복시 관리자에게 문의해주세요.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
@@ -59,7 +59,7 @@ public CalendarResponseDto findCalendars(@PathVariable("calendarId") String cale
     @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified()")
     @Operation(summary = "홈페이지 캘린더 조회 API", description = "홈페이지 캘린더 조회 API 입니다.")
     @ApiResponses({
-            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
+            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = CalendarResponseDto.class))),
             @ApiResponse(responseCode = "4000", description = "캘린더를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4000", description = "로그인된 사용자를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4012", description = "접근 권한이 없습니다. 다시 로그인 해주세요. 문제 반복시 관리자에게 문의해주세요.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
@@ -75,7 +75,7 @@ public CalendarResponseDto findHomeCalendar() {
     @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
             "@securityService.isAdminOrPresidentOrVicePresident()")
     @ApiResponses({
-            @ApiResponse(responseCode = "201", description = "CREATED", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
+            @ApiResponse(responseCode = "201", description = "CREATED", content = @Content(mediaType = "application/json", schema = @Schema(implementation = CalendarResponseDto.class))),
             @ApiResponse(responseCode = "4001", description = "이미 존재하는 캘린더 입니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4000", description = "로그인된 사용자를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4012", description = "접근 권한이 없습니다. 다시 로그인 해주세요. 문제 반복시 관리자에게 문의해주세요.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
@@ -93,7 +93,7 @@ public CalendarResponseDto createCalendar(
     @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
             "@securityService.isAdminOrPresidentOrVicePresident()")
     @ApiResponses({
-            @ApiResponse(responseCode = "201", description = "CREATED", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
+            @ApiResponse(responseCode = "201", description = "CREATED", content = @Content(mediaType = "application/json", schema = @Schema(implementation = CalendarResponseDto.class))),
             @ApiResponse(responseCode = "4000", description = "캘린더를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4000", description = "로그인된 사용자를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
             @ApiResponse(responseCode = "4012", description = "접근 권한이 없습니다. 다시 로그인 해주세요. 문제 반복시 관리자에게 문의해주세요.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
@@ -106,4 +106,21 @@ public CalendarResponseDto updateCalendar(
     ) {
         return calendarService.updateCalendar(calendarId, calendarUpdateRequestDto, image);
     }
+
+    @DeleteMapping(value = "/{calendarId}")
+    @Operation(summary = "캘린더 삭제 API", description = "캘린더 삭제 API 입니다.")
+    @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
+        "@securityService.isAdminOrPresidentOrVicePresident()")
+    @ApiResponses({
+        @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = CalendarResponseDto.class))),
+        @ApiResponse(responseCode = "4000", description = "캘린더를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
+        @ApiResponse(responseCode = "4000", description = "로그인된 사용자를 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
+        @ApiResponse(responseCode = "4012", description = "접근 권한이 없습니다. 다시 로그인 해주세요. 문제 반복시 관리자에게 문의해주세요.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class))),
+        @ApiResponse(responseCode = "5000", description = "User id checked, but exception occurred", content = @Content(mediaType = "application/json", schema = @Schema(implementation = BadRequestException.class)))
+    })
+    public CalendarResponseDto deleteCalendar(
+        @PathVariable("calendarId") String calendarId
+    ) {
+        return calendarService.deleteCalendar(calendarId);
+    }
 }
diff --git a/src/main/java/net/causw/application/calendar/CalendarService.java b/src/main/java/net/causw/application/calendar/CalendarService.java
index f9b379cc8..69a1b5d3f 100644
--- a/src/main/java/net/causw/application/calendar/CalendarService.java
+++ b/src/main/java/net/causw/application/calendar/CalendarService.java
@@ -3,6 +3,7 @@
 import lombok.RequiredArgsConstructor;
 import net.causw.adapter.persistence.calendar.Calendar;
 import net.causw.adapter.persistence.repository.calendar.CalendarRepository;
+import net.causw.adapter.persistence.repository.uuidFile.CalendarAttachImageRepository;
 import net.causw.adapter.persistence.uuidFile.joinEntity.CalendarAttachImage;
 import net.causw.adapter.persistence.uuidFile.UuidFile;
 import net.causw.application.dto.calendar.CalendarCreateRequestDto;
@@ -27,6 +28,7 @@
 public class CalendarService {
     private final CalendarRepository calendarRepository;
     private final UuidFileService uuidFileService;
+    private final CalendarAttachImageRepository calendarAttachImageRepository;
 
     @Transactional(readOnly = true)
     public CalendarsResponseDto findCalendarByYear(Integer year) {
@@ -114,4 +116,24 @@ public CalendarResponseDto updateCalendar(String calendarId, CalendarUpdateReque
 
         return CalendarDtoMapper.INSTANCE.toCalendarResponseDto(calendarRepository.save(calendar));
     }
+
+    /**
+     * @param calendarId 삭제할 캘린더 Id
+     * @return 캘린더 단일 dto
+     */
+    @Transactional
+    public CalendarResponseDto deleteCalendar(String calendarId) {
+        Calendar calendar = calendarRepository.findById(calendarId).orElseThrow(
+            () -> new BadRequestException(
+                ErrorCode.ROW_DOES_NOT_EXIST,
+                MessageUtil.CALENDAR_NOT_FOUND
+            )
+        );
+        
+        calendarAttachImageRepository.delete(calendar.getCalendarAttachImage());
+        uuidFileService.deleteFile(calendar.getCalendarAttachImage().uuidFile);
+        calendarRepository.delete(calendar);
+
+        return CalendarDtoMapper.INSTANCE.toCalendarResponseDto(calendar);
+    }
 }
diff --git a/src/test/java/net/causw/calendar/CalendarServiceTest.java b/src/test/java/net/causw/calendar/CalendarServiceTest.java
new file mode 100644
index 000000000..2b3024398
--- /dev/null
+++ b/src/test/java/net/causw/calendar/CalendarServiceTest.java
@@ -0,0 +1,109 @@
+package net.causw.calendar;
+
+import java.util.Optional;
+import net.causw.adapter.persistence.calendar.Calendar;
+import net.causw.adapter.persistence.repository.calendar.CalendarRepository;
+import net.causw.adapter.persistence.repository.uuidFile.CalendarAttachImageRepository;
+import net.causw.adapter.persistence.uuidFile.UuidFile;
+import net.causw.adapter.persistence.uuidFile.joinEntity.CalendarAttachImage;
+import net.causw.application.calendar.CalendarService;
+import net.causw.application.dto.calendar.CalendarResponseDto;
+import net.causw.application.uuidFile.UuidFileService;
+import net.causw.domain.exceptions.BadRequestException;
+import net.causw.domain.model.enums.uuidFile.FilePath;
+import net.causw.domain.model.util.MessageUtil;
+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;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class CalendarServiceTest {
+
+  @InjectMocks
+  CalendarService calendarService;
+
+  @Mock
+  private CalendarRepository calendarRepository;
+  @Mock
+  private UuidFileService uuidFileService;
+  @Mock
+  private CalendarAttachImageRepository calendarAttachImageRepository;
+
+
+  @Nested
+  @DisplayName("Calendar 삭제 테스트")
+  class CalendarDeleteTest {
+
+    UuidFile mockUuidFile;
+    Calendar mockCalendar;
+    CalendarAttachImage mockAttachImage;
+
+    // 각 테스트마다 초기화
+    @BeforeEach
+    void setUp() {
+      mockUuidFile = UuidFile.of(
+          "uuid",
+          "fileKey",
+          "fileUrl",
+          "rawFileName",
+          "png",
+          FilePath.CALENDAR
+      );
+      mockCalendar = Calendar.of(2025, 3, mockUuidFile);
+      mockAttachImage = mockCalendar.getCalendarAttachImage();
+
+
+    }
+
+    @Test
+    @DisplayName("Calendar 삭제 성공")
+    void deleteCalendarSuccess() {
+      // given
+      String calendarId = "testId";
+      given(calendarRepository.findById(calendarId)).willReturn(Optional.of(mockCalendar));
+
+      // when
+      CalendarResponseDto result = calendarService.deleteCalendar(calendarId);
+
+      // then
+      assertThat(result).isNotNull();
+      assertThat(result.getYear()).isEqualTo(2025);
+      assertThat(result.getMonth()).isEqualTo(3);
+
+      verify(calendarRepository, times(1)).findById(calendarId);
+      verify(calendarAttachImageRepository, times(1)).delete(mockAttachImage);
+      verify(uuidFileService, times(1)).deleteFile(mockUuidFile);
+      verify(calendarRepository, times(1)).delete(mockCalendar);
+    }
+
+    @Test
+    @DisplayName("Calendar 없을 시 삭제 실패")
+    void deleteCalendarFailedWhenCalendarNotExist() {
+      // given
+      String calendarId = "notExistsId";
+      given(calendarRepository.findById(calendarId)).willReturn(Optional.empty());
+
+      // when & then
+      assertThatThrownBy(() -> calendarService.deleteCalendar(calendarId))
+          .isInstanceOf(BadRequestException.class)
+          .hasMessageContaining(MessageUtil.CALENDAR_NOT_FOUND);
+
+      verify(calendarRepository, times(1)).findById(calendarId);
+      verify(calendarAttachImageRepository, never()).delete(any());
+      verify(uuidFileService, never()).deleteFile(any());
+      verify(calendarRepository, never()).delete(any());
+    }
+  }
+
+}


Reviews:
user: login: pstar987
body: 혹시 테스트가 다 된 구현일까요? 되었다면 함께 테스트 된 자료 캡쳐해서 올려주세요!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T15:06:05Z
user: login: bingle625
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T01:06:59Z
user: login: bingle625
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T01:07:22Z
user: login: bingle625
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T01:09:40Z
user: login: pstar987
body: 테스트 코드까지 짜시느라 고생하셨습니다!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T03:37:04Z
user: login: pstar987
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T03:37:24Z
user: login: pstar987
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T04:19:27Z
user: login: bingle625
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T05:03:37Z
user: login: ja7811
body: 확인했습니다. 수고하셨어용
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T08:12:53Z
body: 확인했습니다. 수고하셨습니다.
state: APPROVED
author_association: NONE
submitted_at: 2025-03-16T12:06:55Z
user: login: GY102912
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T15:07:00Z
user: login: LHC0312
body: 수고하셨습니다.
state: APPROVED
author_association: NONE
submitted_at: 2025-03-16T15:22:22Z

Review comments:
diff_hunk: @@ -106,4 +107,21 @@ public CalendarResponseDto updateCalendar(
     ) {
         return calendarService.updateCalendar(calendarId, calendarUpdateRequestDto, image);
     }
+
+    @DeleteMapping(value = "/{calendarId}")
+    @Operation(summary = "캘린더 삭제 API", description = "캘린더 삭제 API 입니다.")
+    @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
+        "@securityService.isAdminOrPresidentOrVicePresident()")
+    @ApiResponses({
+        @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
path: src/main/java/net/causw/adapter/web/CalendarController.java
user: login: pstar987
body: 스키마 string이 아니라 응답값으로 명확히 보이게 변경해주세요. 그리고 현재 캘린더 쪽이 다 string.class로 되어있는데 그부분도 함께 변경해서 반영해주시면 감사하겠습니다. 
created_at: 2025-03-15T14:52:40Z
author_association: CONTRIBUTOR
path: src/main/java/net/causw/application/dto/calendar/CalendarDeleteRequestDto.java
user: login: pstar987
body: 이 dto는 사용하지 않는것으로 확인되는데 맞나요?
created_at: 2025-03-15T14:56:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,4 +116,24 @@ public CalendarResponseDto updateCalendar(String calendarId, CalendarUpdateReque
 
         return CalendarDtoMapper.INSTANCE.toCalendarResponseDto(calendarRepository.save(calendar));
     }
+
+    /**
+     * @param calendarId 삭제할 캘린더 Id
+     * @return 캘린더 단일 dto
+     */
+    @Transactional
+    public CalendarResponseDto deleteCalendar(final String calendarId) {
path: src/main/java/net/causw/application/calendar/CalendarService.java
user: login: pstar987
body: 저희가 현재 코드상으로 파라미터에 final을 붙여서 불변성을 유지하진 않는데 혹시 별도로 의도하신 바가 있으신가요?
created_at: 2025-03-15T14:59:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,4 +107,21 @@ public CalendarResponseDto updateCalendar(
     ) {
         return calendarService.updateCalendar(calendarId, calendarUpdateRequestDto, image);
     }
+
+    @DeleteMapping(value = "/{calendarId}")
+    @Operation(summary = "캘린더 삭제 API", description = "캘린더 삭제 API 입니다.")
+    @PreAuthorize("@securityService.isActiveAndNotNoneUserAndAcademicRecordCertified() and " +
+        "@securityService.isAdminOrPresidentOrVicePresident()")
+    @ApiResponses({
+        @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = String.class))),
path: src/main/java/net/causw/adapter/web/CalendarController.java
user: login: bingle625
body: 네 알겠습니다.
created_at: 2025-03-16T01:06:59Z
author_association: CONTRIBUTOR
path: src/main/java/net/causw/application/dto/calendar/CalendarDeleteRequestDto.java
user: login: bingle625
body: 이전에 batch Delete 구현하려다가 미처 삭제하지 못했습니다.
삭제하겠습니다.
created_at: 2025-03-16T01:07:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,4 +116,24 @@ public CalendarResponseDto updateCalendar(String calendarId, CalendarUpdateReque
 
         return CalendarDtoMapper.INSTANCE.toCalendarResponseDto(calendarRepository.save(calendar));
     }
+
+    /**
+     * @param calendarId 삭제할 캘린더 Id
+     * @return 캘린더 단일 dto
+     */
+    @Transactional
+    public CalendarResponseDto deleteCalendar(final String calendarId) {
path: src/main/java/net/causw/application/calendar/CalendarService.java
user: login: bingle625
body: 파라미터로 들어온 값에 재할당을 방지하기 위해서 습관적으로 사용하고 있었습니다.
혹시 관련해서 사용하지 않아야 하는 커스텀이나 정책이 있는 것이라면 참고하도록 하겠습니다.
created_at: 2025-03-16T01:09:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,4 +116,24 @@ public CalendarResponseDto updateCalendar(String calendarId, CalendarUpdateReque
 
         return CalendarDtoMapper.INSTANCE.toCalendarResponseDto(calendarRepository.save(calendar));
     }
+
+    /**
+     * @param calendarId 삭제할 캘린더 Id
+     * @return 캘린더 단일 dto
+     */
+    @Transactional
+    public CalendarResponseDto deleteCalendar(final String calendarId) {
path: src/main/java/net/causw/application/calendar/CalendarService.java
user: login: pstar987
body: 사용하지 않아야 하는 것은 없지만 이부분도 기존의 코드 스타일에서 사용하지 않는 방식이라서요, 되도록이면 기존 코드스타일에 맞춰주세요
created_at: 2025-03-16T04:19:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,4 +116,24 @@ public CalendarResponseDto updateCalendar(String calendarId, CalendarUpdateReque
 
         return CalendarDtoMapper.INSTANCE.toCalendarResponseDto(calendarRepository.save(calendar));
     }
+
+    /**
+     * @param calendarId 삭제할 캘린더 Id
+     * @return 캘린더 단일 dto
+     */
+    @Transactional
+    public CalendarResponseDto deleteCalendar(final String calendarId) {
path: src/main/java/net/causw/application/calendar/CalendarService.java
user: login: bingle625
body: 네 알겠습니다.
created_at: 2025-03-16T05:03:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: bingle625
created_at: 2025-03-16T02:41:33Z
body: 테스트 코드 작성 후 테스트 결과와
스웨거 테스트 결과 이미지를 본문에 추가하였으니 확인해주시면 감사하겠습니다!
author_association: CONTRIBUTOR
user: login: GY102912
created_at: 2025-03-16T15:04:17Z
body: 확인했습니다 수고하셨습니다!
author_association: CONTRIBUTOR