diff --git a/src/main/java/com/dnd/moddo/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dnd/moddo/common/exception/GlobalExceptionHandler.java index d74c08da..32a09749 100644 --- a/src/main/java/com/dnd/moddo/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dnd/moddo/common/exception/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MultipartException; import com.dnd.moddo.common.logging.DiscordMessage; import com.dnd.moddo.common.logging.ErrorNotifier; @@ -87,6 +88,14 @@ public ResponseEntity handleConstraintViolation( .body(new ErrorResponse(400, e.getMessage())); } + @ExceptionHandler(MultipartException.class) + public ResponseEntity handleMultipartException(MultipartException exception) { + LoggingUtils.warn(exception); + + return ResponseEntity.badRequest() + .body(new ErrorResponse(400, "잘못된 multipart 요청입니다.")); + } + @ExceptionHandler({ModdoException.class}) public ResponseEntity handleDefineException(ModdoException exception) { LoggingUtils.warn(exception); diff --git a/src/main/java/com/dnd/moddo/common/logging/LoggingUtils.java b/src/main/java/com/dnd/moddo/common/logging/LoggingUtils.java index 23622b4a..27382c0d 100644 --- a/src/main/java/com/dnd/moddo/common/logging/LoggingUtils.java +++ b/src/main/java/com/dnd/moddo/common/logging/LoggingUtils.java @@ -2,6 +2,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MultipartException; import com.dnd.moddo.common.exception.ModdoException; @@ -24,6 +25,11 @@ public static void warn(MethodArgumentTypeMismatchException exception) { log.warn(message + "\n \t {}", exception); } + public static void warn(MultipartException exception) { + String message = getExceptionMessage(exception.getMessage()); + log.warn(message + "\n \t {}", exception); + } + private static String getExceptionMessage(String message) { if (message == null || message.isBlank()) { return ""; diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandExpenseService.java b/src/main/java/com/dnd/moddo/event/application/command/CommandExpenseService.java index cb002aa7..d78faa45 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandExpenseService.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandExpenseService.java @@ -9,9 +9,11 @@ import com.dnd.moddo.event.application.impl.ExpenseDeleter; import com.dnd.moddo.event.application.impl.ExpenseReader; import com.dnd.moddo.event.application.impl.ExpenseUpdater; +import com.dnd.moddo.event.application.impl.PaymentRequestReader; import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.application.impl.SettlementValidator; import com.dnd.moddo.event.domain.expense.Expense; +import com.dnd.moddo.event.domain.expense.exception.ExpenseModificationLockedException; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.presentation.request.ExpenseImageRequest; import com.dnd.moddo.event.presentation.request.ExpenseRequest; @@ -33,8 +35,11 @@ public class CommandExpenseService { private final CommandMemberExpenseService commandMemberExpenseService; private final SettlementReader settlementReader; private final SettlementValidator settlementValidator; + private final PaymentRequestReader paymentRequestReader; public ExpensesResponse createExpenses(Long groupId, ExpensesRequest request) { + validateExpenseModifiable(groupId); + List expenses = request.expenses() .stream() .map(e -> createExpense(groupId, e)) @@ -58,6 +63,7 @@ public ExpenseResponse update(Long userId, Long expenseId, Long settlementId, Ex Settlement settlement = settlementReader.read(settlementId); settlementValidator.checkSettlementAuthor(settlement, userId); + validateExpenseModifiable(settlementId); expense = expenseUpdater.update(expenseId, request); List memberExpenseResponses = commandMemberExpenseService.update(expenseId, @@ -70,6 +76,7 @@ public ExpenseResponse update(Long userId, Long expenseId, Long settlementId, Ex public void updateImgUrl(Long userId, Long groupId, Long expenseId, ExpenseImageRequest request) { Settlement settlement = settlementReader.read(groupId); settlementValidator.checkSettlementAuthor(settlement, userId); + validateExpenseModifiable(groupId); expenseUpdater.updateImgUrl(expenseId, request); } @@ -80,9 +87,16 @@ public void delete(Long userId, Long expenseId, Long settlementId) { Settlement settlement = settlementReader.read(settlementId); settlementValidator.checkSettlementAuthor(settlement, userId); + validateExpenseModifiable(settlementId); commandMemberExpenseService.deleteAllByExpenseId(expenseId); expenseDeleter.delete(expense); cacheEvictor.evictSettlementHeader(settlementId); } + + private void validateExpenseModifiable(Long settlementId) { + if (paymentRequestReader.existsBySettlementId(settlementId)) { + throw new ExpenseModificationLockedException(settlementId); + } + } } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java index eef13ff7..108a4a68 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java @@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional; import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.exception.MemberNotFoundException; import com.dnd.moddo.event.domain.member.type.MemberSortType; import com.dnd.moddo.event.infrastructure.MemberQueryRepository; @@ -45,6 +46,10 @@ public boolean existsUnpaidMember(Long settlementId) { return memberRepository.existsBySettlementIdAndIsPaidFalse(settlementId); } + public boolean existsPaidParticipant(Long settlementId) { + return memberRepository.existsBySettlementIdAndRoleAndIsPaidTrue(settlementId, ExpenseRole.PARTICIPANT); + } + public List findAssignedMembersBySettlementId(Long settlementId) { return findAllBySettlementId(settlementId).stream() .filter(Member::isAssigned) diff --git a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java index a69a0f86..a3fa4c96 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/PaymentRequestReader.java @@ -25,6 +25,7 @@ public class PaymentRequestReader { private final PaymentRequestRepository paymentRequestRepository; private final MemberExpenseReader memberExpenseReader; + private final MemberReader memberReader; public PaymentRequestsResponse findByTargetUserId(Long targetUserId) { List paymentRequests = paymentRequestRepository.findByTargetUserId(targetUserId) @@ -63,7 +64,8 @@ public PaymentRequestsResponse findByTargetUserId(Long targetUserId) { } public boolean existsBySettlementId(Long settlementId) { - return paymentRequestRepository.existsBySettlementId(settlementId); + return paymentRequestRepository.existsBySettlementId(settlementId) + || memberReader.existsPaidParticipant(settlementId); } public Map findLatestRequestByMemberId(Long settlementId) { diff --git a/src/main/java/com/dnd/moddo/event/domain/expense/exception/ExpenseModificationLockedException.java b/src/main/java/com/dnd/moddo/event/domain/expense/exception/ExpenseModificationLockedException.java new file mode 100644 index 00000000..9407844e --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/domain/expense/exception/ExpenseModificationLockedException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.event.domain.expense.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class ExpenseModificationLockedException extends ModdoException { + public ExpenseModificationLockedException(Long settlementId) { + super(HttpStatus.FORBIDDEN, "입금 확인 요청 또는 입금 완료 상태가 있어 지출내역을 수정할 수 없습니다. (Settlement ID: " + settlementId + ")"); + } +} diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java index 658b7862..97487325 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java @@ -9,6 +9,7 @@ import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.member.exception.MemberNotFoundException; +import com.dnd.moddo.event.domain.member.ExpenseRole; public interface MemberRepository extends JpaRepository { @@ -39,6 +40,18 @@ select count(gm) > 0 """) boolean existsBySettlementIdAndIsPaidFalse(@Param("settlementId") Long settlementId); + @Query(""" + select count(gm) > 0 + from Member gm + where gm.settlement.id = :settlementId + and gm.role = :role + and gm.isPaid = true + """) + boolean existsBySettlementIdAndRoleAndIsPaidTrue( + @Param("settlementId") Long settlementId, + @Param("role") ExpenseRole role + ); + default Member getById(Long id) { return findById(id) .orElseThrow(() -> new MemberNotFoundException(id)); diff --git a/src/test/java/com/dnd/moddo/common/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/dnd/moddo/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..bdbadb8d --- /dev/null +++ b/src/test/java/com/dnd/moddo/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,42 @@ +package com.dnd.moddo.common.exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; + +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.ResponseEntity; +import org.springframework.web.multipart.MultipartException; + +import com.dnd.moddo.common.logging.DiscordMessage; +import com.dnd.moddo.common.logging.ErrorNotifier; + +@ExtendWith(MockitoExtension.class) +class GlobalExceptionHandlerTest { + + @InjectMocks + private GlobalExceptionHandler globalExceptionHandler; + + @Mock + private ErrorNotifier errorNotifier; + + @Test + void givenInvalidMultipartRequest_thenReturnBadRequestWithoutErrorNotification() { + // given + MultipartException exception = new MultipartException("Failed to parse multipart servlet request"); + + // when + ResponseEntity response = globalExceptionHandler.handleMultipartException(exception); + + // then + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()) + .isEqualTo(new ErrorResponse(400, "잘못된 multipart 요청입니다.")); + then(errorNotifier).should(never()).notifyError(any(DiscordMessage.class)); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/expense/service/CommandExpenseServiceTest.java b/src/test/java/com/dnd/moddo/domain/expense/service/CommandExpenseServiceTest.java index 9eca3d06..b8a05456 100644 --- a/src/test/java/com/dnd/moddo/domain/expense/service/CommandExpenseServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/expense/service/CommandExpenseServiceTest.java @@ -22,9 +22,11 @@ import com.dnd.moddo.event.application.impl.ExpenseDeleter; import com.dnd.moddo.event.application.impl.ExpenseReader; import com.dnd.moddo.event.application.impl.ExpenseUpdater; +import com.dnd.moddo.event.application.impl.PaymentRequestReader; import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.application.impl.SettlementValidator; import com.dnd.moddo.event.domain.expense.Expense; +import com.dnd.moddo.event.domain.expense.exception.ExpenseModificationLockedException; import com.dnd.moddo.event.domain.expense.exception.ExpenseNotFoundException; import com.dnd.moddo.event.domain.expense.exception.ExpenseNotSettlementException; import com.dnd.moddo.event.domain.settlement.Settlement; @@ -53,6 +55,8 @@ class CommandExpenseServiceTest { @Mock private SettlementValidator settlementValidator; @Mock + private PaymentRequestReader paymentRequestReader; + @Mock private CommandMemberExpenseService commandMemberExpenseService; @Mock private CacheEvictor cacheEvictor; @@ -117,6 +121,7 @@ void updateSuccess() { when(expenseReader.findByExpenseId(eq(expenseId))).thenReturn(mockExpense); when(settlementReader.read(eq(groupId))).thenReturn(mockSettlement); doNothing().when(settlementValidator).checkSettlementAuthor(eq(mockSettlement), eq(userId)); + when(paymentRequestReader.existsBySettlementId(groupId)).thenReturn(false); when(expenseUpdater.update(eq(expenseId), eq(expenseRequest))).thenReturn(mockExpense); when(commandMemberExpenseService.update(eq(expenseId), any())).thenReturn( @@ -128,10 +133,36 @@ void updateSuccess() { //then assertThat(response).isNotNull(); verify(mockExpense, times(1)).validateSettlement(groupId); + verify(paymentRequestReader, times(1)).existsBySettlementId(groupId); verify(expenseUpdater, times(1)).update(expenseId, expenseRequest); verify(cacheEvictor, times(1)).evictSettlementHeader(groupId); } + @DisplayName("입금 확인 요청 또는 입금 완료 참여자가 있으면 지출내역을 수정할 수 없다.") + @Test + void updateLockedByPaymentProgress() { + // given + Long userId = 1L; + Long settlementId = 1L; + Long expenseId = 10L; + ExpenseRequest expenseRequest = mock(ExpenseRequest.class); + Settlement mockSettlement = new Settlement(settlementId, userId, "정산", null, null, null, null, null, null, 1L, + "code"); + Expense mockExpense = mock(Expense.class); + + when(expenseReader.findByExpenseId(expenseId)).thenReturn(mockExpense); + when(settlementReader.read(settlementId)).thenReturn(mockSettlement); + when(paymentRequestReader.existsBySettlementId(settlementId)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> commandExpenseService.update(userId, expenseId, settlementId, expenseRequest)) + .isInstanceOf(ExpenseModificationLockedException.class); + + verify(expenseUpdater, never()).update(anyLong(), any()); + verify(commandMemberExpenseService, never()).update(anyLong(), any()); + verify(cacheEvictor, never()).evictSettlementHeader(anyLong()); + } + @DisplayName("업데이트하려는 지출 내역을 찾을 수 없을때 예외를 발생시킨다.") @Test void updateNotFound() { @@ -164,6 +195,7 @@ void deleteSuccess() { when(expenseReader.findByExpenseId(eq(expenseId))).thenReturn(mockExpense); when(settlementReader.read(eq(settlementId))).thenReturn(mockSettlement); doNothing().when(settlementValidator).checkSettlementAuthor(eq(mockSettlement), eq(userId)); + when(paymentRequestReader.existsBySettlementId(settlementId)).thenReturn(false); doNothing().when(commandMemberExpenseService).deleteAllByExpenseId(eq(expenseId)); doNothing().when(expenseDeleter).delete(eq(mockExpense)); @@ -173,11 +205,36 @@ void deleteSuccess() { //then verify(mockExpense, times(1)).validateSettlement(settlementId); + verify(paymentRequestReader, times(1)).existsBySettlementId(settlementId); verify(commandMemberExpenseService, times(1)).deleteAllByExpenseId(eq(expenseId)); verify(expenseDeleter, times(1)).delete(eq(mockExpense)); verify(cacheEvictor, times(1)).evictSettlementHeader(settlementId); } + @DisplayName("입금 확인 요청 또는 입금 완료 참여자가 있으면 지출내역을 삭제할 수 없다.") + @Test + void deleteLockedByPaymentProgress() { + // given + Long userId = 1L; + Long settlementId = 1L; + Long expenseId = 10L; + Settlement mockSettlement = new Settlement(settlementId, userId, "정산", null, null, null, null, null, null, 1L, + "code"); + Expense mockExpense = mock(Expense.class); + + when(expenseReader.findByExpenseId(expenseId)).thenReturn(mockExpense); + when(settlementReader.read(settlementId)).thenReturn(mockSettlement); + when(paymentRequestReader.existsBySettlementId(settlementId)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> commandExpenseService.delete(userId, expenseId, settlementId)) + .isInstanceOf(ExpenseModificationLockedException.class); + + verify(commandMemberExpenseService, never()).deleteAllByExpenseId(anyLong()); + verify(expenseDeleter, never()).delete(any()); + verify(cacheEvictor, never()).evictSettlementHeader(anyLong()); + } + @DisplayName("삭제하려는 지출내역이 해당 정산에 속하지 않으면 예외가 발생한다.") @Test void deleteNotSettlement() { @@ -251,6 +308,7 @@ void updateImgUrlSuccess() { when(settlementReader.read(groupId)).thenReturn(mockSettlement); doNothing().when(settlementValidator).checkSettlementAuthor(mockSettlement, userId); + when(paymentRequestReader.existsBySettlementId(groupId)).thenReturn(false); // when commandExpenseService.updateImgUrl(userId, groupId, expenseId, request); @@ -258,6 +316,7 @@ void updateImgUrlSuccess() { // then verify(settlementReader, times(1)).read(groupId); verify(settlementValidator, times(1)).checkSettlementAuthor(mockSettlement, userId); + verify(paymentRequestReader, times(1)).existsBySettlementId(groupId); verify(expenseUpdater, times(1)).updateImgUrl(expenseId, request); } diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java index 5b2ce328..dffec496 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/QueryPaymentRequestServiceTest.java @@ -50,7 +50,7 @@ void findByTargetUserId() { } @Test - @DisplayName("정산에 생성된 입금 확인 요청이 있는지 확인할 수 있다.") + @DisplayName("정산에 생성된 입금 확인 요청 또는 입금 완료 참여자가 있는지 확인할 수 있다.") void existsBySettlementId() { when(paymentRequestReader.existsBySettlementId(1L)).thenReturn(true); diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java index e47fbd03..f44c7130 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestReaderTest.java @@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.dnd.moddo.event.application.impl.MemberExpenseReader; +import com.dnd.moddo.event.application.impl.MemberReader; import com.dnd.moddo.event.application.impl.PaymentRequestReader; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; @@ -35,6 +36,9 @@ class PaymentRequestReaderTest { @Mock private MemberExpenseReader memberExpenseReader; + @Mock + private MemberReader memberReader; + @InjectMocks private PaymentRequestReader paymentRequestReader; @@ -140,6 +144,20 @@ void existsBySettlementId() { assertThat(result).isTrue(); verify(paymentRequestRepository).existsBySettlementId(1L); + verify(memberReader, never()).existsPaidParticipant(1L); + } + + @Test + @DisplayName("입금 확인 요청이 없어도 입금 완료 참여자가 있으면 수정 잠금 상태로 판단한다.") + void existsBySettlementIdWhenPaidParticipantExists() { + when(paymentRequestRepository.existsBySettlementId(1L)).thenReturn(false); + when(memberReader.existsPaidParticipant(1L)).thenReturn(true); + + boolean result = paymentRequestReader.existsBySettlementId(1L); + + assertThat(result).isTrue(); + verify(paymentRequestRepository).existsBySettlementId(1L); + verify(memberReader).existsPaidParticipant(1L); } @Test